{
  "openapi": "3.1.0",
  "info": {
    "title": "Capturia API",
    "version": "v1",
    "summary": "Capturia public API for partner and customer integrations",
    "description": "The Capturia API lets you integrate Capturia with third-party tools (Make, Zapier,\ncustom code, external CRMs) through REST endpoints authenticated with an API key.\n\n## Quickstart\n\n1. Create an API key in the dashboard: `Admin → Integrations → API Keys`\n2. Pick the matching preset (`Lead capture` to push leads from a funnel)\n3. Call `GET /v1/me` to confirm the key works\n4. Capture a lead: `POST /v1/leads/capture`\n\nFull documentation: https://capturia.io/en/developers\n",
    "contact": {
      "name": "Capturia Support",
      "url": "https://capturia.io/en/developers/support",
      "email": "support@capturia.io"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://capturia.io/en/legal/terms"
    },
    "termsOfService": "https://capturia.io/en/legal/terms"
  },
  "servers": [
    {
      "url": "https://api.capturia.io",
      "description": "Production (recommended)"
    },
    {
      "url": "https://app.capturia.io/api",
      "description": "Production (legacy, sunset 2027-05-03)"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Me",
      "description": "Whoami endpoint to confirm an API key works"
    },
    {
      "name": "Usage",
      "description": "Programmatic usage metrics"
    },
    {
      "name": "Leads",
      "description": "Lead capture from external funnels (Make, Zapier, custom code)"
    },
    {
      "name": "Contacts",
      "description": "Full CRUD on contacts (prospects and customers)"
    },
    {
      "name": "Conversations",
      "description": "Unified conversation threads (chat, SMS, call, email)"
    },
    {
      "name": "Bookings",
      "description": "Calendar bookings"
    },
    {
      "name": "Quotes",
      "description": "Quotes and sales proposals"
    },
    {
      "name": "Tags",
      "description": "Tags to categorize contacts"
    },
    {
      "name": "Automations",
      "description": "Automated workflows (follow-ups, sequences, triggers)"
    },
    {
      "name": "Pipeline",
      "description": "Sales pipeline deals and stages"
    },
    {
      "name": "Emails",
      "description": "Send and read transactional emails"
    },
    {
      "name": "Webhooks",
      "description": "Outbound endpoints that notify an external system of events"
    },
    {
      "name": "Presuasion submissions",
      "description": "Form submissions from pre-suasion tools"
    },
    {
      "name": "Events",
      "description": "Ingest custom events that trigger automations, and register a contact\nfor one of the account's events (webinar, challenge, summit, online\nevent)\n"
    }
  ],
  "x-tagGroups": [
    {
      "name": "Core",
      "tags": [
        "Me",
        "Usage",
        "Contacts",
        "Conversations"
      ]
    },
    {
      "name": "Sales",
      "tags": [
        "Bookings",
        "Quotes",
        "Pipeline",
        "Tags"
      ]
    },
    {
      "name": "Communication",
      "tags": [
        "Emails",
        "Automations"
      ]
    },
    {
      "name": "Integration",
      "tags": [
        "Leads",
        "Events",
        "Webhooks",
        "Presuasion submissions"
      ]
    }
  ],
  "paths": {
    "/v1/me": {
      "get": {
        "tags": [
          "Me"
        ],
        "operationId": "getMe",
        "summary": "Confirm an API key works",
        "description": "Minimal whoami endpoint — returns details about the key that authenticates the request\n(name, scopes, inferred preset, expiration, rate limits, client). It's the simplest\nway to confirm that a third-party integration is connected. No mutations, no side\neffects — safe to call from any smoke test script.\n\nNo specific scope is required; any authenticated, active key can call this\nendpoint. Each call consumes 1 unit of the per-key rate limit (so a script stuck in\na loop can't flood the tracking).\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Valid key.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ApiKeyMe"
                    }
                  }
                },
                "example": {
                  "data": {
                    "key_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "key_name": "Acme production",
                    "key_prefix": "cap_live_a1b2c3",
                    "key_format_version": 2,
                    "client_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "client_name": "Acme Corp.",
                    "scopes": [
                      "leads:capture"
                    ],
                    "preset": "lead_capture",
                    "lifecycle_state": "active",
                    "created_at": "2026-04-01T10:00:00.000Z",
                    "last_used_at": "2026-05-04T14:23:11.000Z",
                    "expires_at": null,
                    "rate_limits": {
                      "per_hour": 100
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "tags": [
          "Usage"
        ],
        "operationId": "getUsage",
        "summary": "Read aggregated usage for the authenticated API key",
        "description": "Returns the aggregation buckets (hourly or daily) of the authenticated key's\nusage over a given time range, plus totals computed application-side. Use it to\nsize your usage, monitor latencies, and spot rate limit rejections.\n\nNo specific scope is required (same pattern as GitHub's `/rate_limit`): reading\nyour own metrics is free and always allowed for any authenticated key.\n\nThe range `(to - from)` must be strictly positive and must not exceed\n**90 days**. Returned buckets are sorted by `bucket_start ASC`; windows with no\ntraffic are omitted from the array (no zero-padding).\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "Lower bound (inclusive) of the time range, ISO 8601 UTC.\n",
            "schema": {
              "$ref": "#/components/schemas/Timestamp"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "Upper bound (exclusive) of the time range, ISO 8601 UTC. Must be\nstrictly after `from`, and the range `(to - from)` must be ≤ 90 days.\n",
            "schema": {
              "$ref": "#/components/schemas/Timestamp"
            }
          },
          {
            "name": "granularity",
            "in": "query",
            "required": false,
            "description": "Granularity of the returned buckets. Defaults to `day`.\n",
            "schema": {
              "type": "string",
              "enum": [
                "hour",
                "day"
              ],
              "default": "day"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Usage buckets and totals for the requested range.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/UsageResponse"
                    }
                  }
                },
                "example": {
                  "data": {
                    "api_key_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "from": "2026-05-01T00:00:00.000Z",
                    "to": "2026-05-04T00:00:00.000Z",
                    "granularity": "day",
                    "buckets": [
                      {
                        "bucket_start": "2026-05-01T00:00:00.000Z",
                        "request_count": 1239,
                        "status_2xx": 1200,
                        "status_4xx": 30,
                        "status_5xx": 4,
                        "rate_limited_count": 5,
                        "status_unknown": 0,
                        "latency_p50_ms": 42.5,
                        "latency_p95_ms": 180,
                        "latency_p99_ms": 350
                      }
                    ],
                    "totals": {
                      "request_count": 1239,
                      "status_2xx": 1200,
                      "status_4xx": 30,
                      "status_5xx": 4,
                      "rate_limited_count": 5,
                      "status_unknown": 0
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/leads/capture": {
      "post": {
        "tags": [
          "Leads"
        ],
        "operationId": "captureLead",
        "x-internal-skip-body-validation": true,
        "summary": "Capture a lead from an external funnel",
        "description": "Captures a new lead in Capturia from a third-party funnel, form, or tool\n(Make, Zapier, n8n, custom landing page). Once captured, the lead shows up\nimmediately in **My Prospects** in the dashboard, and the SMS bot takes\nover within 30 seconds if consent and configuration are in\norder.\n\n### Contact already known (email or phone)\nThe contact is resolved by **email first** (case-insensitive),\nthen by **phone**, including secondary contact details already\nattached to a contact. Capturing a lead whose email or phone\nalready exists **reuses and enriches the existing contact** — the\nreturned `contact_id` is that contact's, never a duplicate and\nnever an error. Contact details and core fields that are already filled\nin (name, email, phone, pipeline, value, address...) are not\noverwritten; qualification answers (`custom_fields`), on the other\nhand, are refreshed with the latest values sent — a key that is\nalready present is replaced, a new key is added. A new\nphone number is kept as a secondary number when the contact already\nhas one. If the email and the phone point to two different\ncontacts, the email takes priority and no contact detail is removed\nfrom the other contact.\n\nThe response exposes `contact_was_new` to tell the two outcomes apart:\non an existing contact (`false`), `pipeline` and `assignment_status`\ndescribe the resolved request, not necessarily the persisted state — the\ncontact keeps the pipeline, stage, and sales rep it already has, and\n`ignored_fields` names the fields of the `pipeline` block it did\nnot take (`existing_contact_kept`).\n\n### Received fields not applied (`ignored_fields`)\nA capture that responds 200 tells you what it did not apply. The response\ncarries `ignored_fields`, a `[{ field, reason }]` list that is always\npresent and empty when everything was applied:\n\n- `unknown_field` — a name the API does not know, at the root, in\n  `customData`, or in a block (`pipeline.id`, `utm.utm_source`);\n- `custom_field_not_defined` — a `custom_fields` key with no\n  definition of the same name in the account;\n- `reserved_tag` — a tag with a reserved name (`tags[1]`);\n- `system_not_active` — a `system_id` that is not active;\n- `existing_contact_kept` — a field of the `pipeline` block that the\n  existing contact kept;\n- `sales_rep_not_found` — a sales rep email that matches no\n  active member of the account;\n- `submission_not_recorded` — what only applies with a\n  recorded submission, when none is recorded\n  (`existing_pipeline_contact`, `idempotent_replay`);\n- `duplicate_field` — a copy of a field received from several sources,\n  set aside in favor of another value.\n\nOnly limit: on an existing contact, identity and address fields that\nare already filled in, and ad attribution that is already set\n(first-touch rule), are kept without being named one by one\n(`contact_was_new: false` signals it).\n\nThe lead is captured in all of these cases: this is a setup warning\nto read on the first test of an integration. The list is\ncapped at 50 entries (`ignored_fields_truncated`), with fields you mapped\nby hand listed before the ones a connector adds automatically.\n\n### Pipeline and stage\nThe `pipeline` block designates the pipeline and the stage by their text\nidentifier (`pipeline_slug`, `stage_slug`), which you copy from the dashboard:\nAdministration, API Keys, \"Your pipeline and stage identifiers\"\nsection. A connector that only sends key/value pairs\n(GoHighLevel workflow, Zapier) sends them flat:\n`pipeline.pipeline_slug`, `pipeline.stage_slug`. Without\n`pipeline_slug`, the stage is looked up in the default pipeline.\n\n### Contact already in an open pipeline\nA lead whose contact (resolved by email or phone) is already in\nan open pipeline stage (already handled by a sales rep — not\narchived, stage neither won nor lost)\nis not resubmitted: the response returns `status: existing_pipeline_contact`,\nno new submission is recorded, and the SMS bot is not\ntriggered again, so an ongoing conversation is not overwritten. Newly\nprovided core fields still enrich the existing contact.\n\nRetry deduplication takes precedence: a re-push received within the\n5-minute window is treated as a retry (`status: idempotent_replay`, see\nIdempotency) and keeps the original `activity_id` and\n`original_created_at` — `existing_pipeline_contact` only applies after this\nwindow.\n\n### Idempotency\nThe `Idempotency-Key` header is **optional but recommended** for client-side\nretries. A second request with the same key and the same payload returns the\noriginal response without duplicating the lead. The key is stored for 24h. A second\nrequest with the same key but a different payload returns 409\n`idempotency_conflict`. The underlying RPC also keeps its own\n5-minute dedup window. This window is **per contact and\nindependent of content**: any submission for a contact already captured\nwithin the last 5 minutes is treated as a retry\n(`idempotent_replay`) even if the payload differs — the contact is still\nenriched (including `custom_fields`, which are refreshed), but no\nnew activity, no tag, and no SMS bot follow-up. Updates to the\ncontact remain real: an automation that watches a\ncustom field reacts to the value change, as it does for any\nchange to the contact. This is also what prevents a double\nbot follow-up when a lead resubmits their form twice in a row.\nA different `Idempotency-Key` does not bypass this window: it\napplies in the database after any cache miss. Two intentionally\ndistinct submissions from the same contact only produce two activities\nif they are more than 5 minutes apart.\n\n### Custom error codes\nFor historical reasons, some rejections return codes outside the\nstandard catalog:\n\n- **422** — `invalid_phone`, `missing_consent`, `invalid_email`,\n  `invalid_payload`, `pipeline_not_found`, `stage_not_found` (instead of\n  `validation_error` / `business_rule_violation`). Simplified body shape\n  specific to this route — see the 422 response below.\n- **429** — `rate_limited_ip` when the IP limit (100 req/min) is reached\n  (instead of `rate_limit_exceeded`). The `X-RateLimit-Scope: ip` header\n  identifies the scope of the triggered limit. Simplified body shape (no\n  standard `request_id` or `details`) — to be aligned in a later\n  phase.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "leads:capture"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadCapturePayload"
              },
              "example": {
                "phone": "+15145551234",
                "sms_consent": true,
                "first_name": "Alex",
                "last_name": "Tremblay",
                "email": "alex@example.com",
                "source_label": "funnel-fb-jan",
                "utm": {
                  "source": "facebook",
                  "medium": "cpc",
                  "campaign": "spring-2026"
                },
                "tags": [
                  "lead-chaud",
                  "vu-webinaire"
                ],
                "pipeline": {
                  "pipeline_slug": "ventes-b2b",
                  "stage_slug": "nouveau-lead",
                  "deal_value": 2500,
                  "expected_close_date": "2026-06-30",
                  "assigned_sales_rep_email": "vendeur@example.com"
                },
                "custom_fields": {
                  "type_propriete": "maison_isolee",
                  "delai_vente": "0_3_mois"
                },
                "address": "123 rue Principale, Montréal",
                "postal_code": "H2X 1Y4"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lead captured (`status: created`), idempotent replay of a previous\ncapture (`status: idempotent_replay`), or contact already\nhandled (`status: existing_pipeline_contact` — already in an open\npipeline stage: no new submission is recorded, the\nSMS bot is not triggered, and `activity_id` is `null`). In all\ncases, the returned `contact_id` is stable.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadCaptureResponse"
                },
                "example": {
                  "status": "created",
                  "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                  "activity_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
                  "sms_conversation_id": null,
                  "pipeline": {
                    "pipeline_id": "3d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f80",
                    "pipeline_name": "Ventes B2B",
                    "stage_id": "4e5f6a7b-8c9d-0e1f-2a3b-4c5d6e7f8091",
                    "stage_name": "Nouveau lead"
                  },
                  "original_created_at": null,
                  "assignment_status": "assigned",
                  "contact_was_new": true,
                  "ignored_fields": [],
                  "ignored_fields_truncated": false,
                  "message": "Lead captured. The SMS bot will take over within 30 seconds if consent and configuration are in order."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "description": "Key without the `leads:capture` scope (`insufficient_scope`), or\nan organization that has not yet accepted the API terms of\nuse (`terms_acceptance_required`). Standard error\nenvelope.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  }
                },
                "example": {
                  "error": {
                    "type": "authorization_error",
                    "code": "terms_acceptance_required",
                    "message": "L'organisation doit accepter les conditions d'utilisation de l'API avant de l'utiliser.",
                    "doc_url": "https://capturia.io/fr/developers/api/v1/authentication#terms",
                    "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7"
                  }
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "description": "Payload rejected by validation (`invalid_phone`, `missing_consent`,\n`invalid_email`, `invalid_payload`) or by pipeline resolution\n(`pipeline_not_found`, `stage_not_found`). Simplified shape specific to\nthis route: no `type` or `doc_url`, and `details` is an\narray `[{ field, reason }]` (`field` is empty when the body is not\nan object). Only `system_not_found` (a `system_id` that matches\nno System in the account) comes back in the standard error\nenvelope.\nFor an invalid scalar field, the `message` echoes back the\nreceived value (capped at 120 characters) — it is never stored\nby Capturia; only the names of the received fields are, visible\nin the API key's Recent errors tab. The `request_id` is\nalways in the `X-Request-ID` header.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "required": [
                        "error"
                      ],
                      "properties": {
                        "error": {
                          "type": "object",
                          "required": [
                            "code",
                            "message"
                          ],
                          "properties": {
                            "code": {
                              "type": "string",
                              "enum": [
                                "invalid_phone",
                                "missing_consent",
                                "invalid_email",
                                "invalid_payload",
                                "pipeline_not_found",
                                "stage_not_found"
                              ]
                            },
                            "message": {
                              "type": "string",
                              "description": "Explanation of the rejection. For an invalid scalar field,\nprefixed with `Received <value> for field \"<field>\" —`.\n"
                            },
                            "request_id": {
                              "type": "string",
                              "pattern": "^req_[0-9A-HJKMNP-TV-Z]{26}$"
                            },
                            "details": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "required": [
                                  "field",
                                  "reason"
                                ],
                                "properties": {
                                  "field": {
                                    "type": "string",
                                    "description": "Path of the offending field (`phone`,\n`pipeline.stage_slug`, `custom_fields.ma_cle`).\n"
                                  },
                                  "reason": {
                                    "type": "string"
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    {
                      "type": "object",
                      "description": "Standard error envelope, for `system_not_found`:\n`system_id` matches no System in the account, and no\ncontact is created.\n",
                      "required": [
                        "error"
                      ],
                      "properties": {
                        "error": {
                          "$ref": "#/components/schemas/ErrorEnvelope"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "error": {
                    "code": "invalid_phone",
                    "message": "Received \"phone_number\" for field \"phone\" — Phone must be a valid number — accepted formats: E.164 (+15141234567), 10-digit NANP (5141234567), 11-digit with country code (15141234567), or formatted variants (\"(514) 123-4567\", \"1-514-123-4567\")",
                    "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                    "details": [
                      {
                        "field": "phone",
                        "reason": "Phone must be a valid number — accepted formats: E.164 (+15141234567), 10-digit NANP (5141234567), 11-digit with country code (15141234567), or formatted variants (\"(514) 123-4567\", \"1-514-123-4567\")"
                      }
                    ]
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/events": {
      "post": {
        "tags": [
          "Events"
        ],
        "operationId": "ingestEvent",
        "summary": "Push a custom event that triggers automations",
        "description": "Generic inbound webhook per account: an external source (Zapier, Make,\nn8n, a client backend, a third-party tool) pushes a named event with\nits data. Capturia resolves the contact (`external_id` → `phone` →\n`email`), enriches the contact non-destructively, fills in the mapped custom\nfields, records the event on the contact, then starts\nthe automations whose \"Custom event\" trigger\nlistens for this event name — with the `payload` available in\nmessages through `{{event.<key>}}`.\n\n### Contact resolution\nAt least one identifier is required (`external_id`, `email` or `phone`).\nIf no contact matches, a minimal contact is created — unless\n`contact.create_if_missing` is `false`, in which case the request returns\n`404 contact_not_found` without recording anything. If the identifiers\nonly match an **archived** contact, the event is recorded on it\n(for traceability) but the contact is not modified and no automation\nis enrolled (`contact_archived: true`).\n\n### Consent (Law 25 / CASL)\nReceiving an event does **not** establish any communication\nconsent. A contact created through this path cannot receive\nSMS/email until consent is captured through another flow;\na triggered automation that tries to send without consent\nis blocked by the execution engine's guards.\n\n### Optional HMAC signature\nIn addition to the API key, the request can carry a signature\n`X-Capturia-Signature: t=<unix>,v1=<hex>` — an HMAC-SHA256 of\n`${t}.${raw body}` computed with **the raw API key as the secret**,\nwith a 5-minute anti-replay window. As soon as the header is present,\nvalidation is strict (fail-closed): an invalid signature = `401\ninvalid_signature`. Without the header, the API key alone authenticates the request.\n\n### Idempotency\nThe `Idempotency-Key` header is optional but recommended for\nretries: a second request with the same key and the same payload returns the\noriginal response without ingesting the event again (stored for 24h; a\ndifferent payload = `409 idempotency_conflict`). In addition, automation\nenrollment has its own 5-minute deduplication per contact and per\nautomation.\n\n### Custom error codes\n- **429** — `rate_limited_ip` when the IP rate limit (120 req/min) is\n  reached, with header `X-RateLimit-Scope: ip`. Per-key limit: 60 req/min\n  (adjustable per key).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "events:ingest"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EventIngestPayload"
              },
              "example": {
                "event": "abandon_panier",
                "contact": {
                  "external_id": "user-4821",
                  "external_source": "mon-backend",
                  "email": "alex@example.com",
                  "first_name": "Alex"
                },
                "payload": {
                  "montant": 249.99,
                  "produit": "Forfait Pro",
                  "url_panier": "https://boutique.example.com/panier/abc123"
                },
                "custom_fields": {
                  "plan_vise": "pro"
                },
                "source_label": "Boutique en ligne"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Event ingested (`status: ingested`). `enrolled_executions` tells you\nhow many automations started; `0` means no\nactive automation listens for this event name — the event is\nstill recorded on the contact.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventIngestResponse"
                },
                "example": {
                  "status": "ingested",
                  "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                  "contact_created": false,
                  "contact_archived": false,
                  "activity_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
                  "enrolled_executions": 1,
                  "event": "abandon_panier",
                  "message": "Event ingested. 1 automation(s) enrolled."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/event-registrations": {
      "post": {
        "tags": [
          "Events"
        ],
        "operationId": "createEventRegistration",
        "summary": "Register a contact for an event",
        "description": "Registers an existing contact of the account for a published event\n(webinar, challenge, summit, online event). The person then receives\nthe event's messages, just as if they had registered\nthrough its page.\n\nOne registration per person and per edition: a second call\nfor the same contact and the same edition returns the same registration with\n`200` and `created: false`, without resending the confirmation. It updates the\nlanguage, and re-registers a person who had said they would not\nattend; a person who asked to stop receiving messages stays\nopted out. The call is therefore safe to retry, without an\n`Idempotency-Key` header.\n\nRequires the plan's Event support module\n(`403 feature_not_available` otherwise) and the `contacts:write` scope.\n\n### Rejections\n\n- `404 resource_not_found` with `details.resource`: `contact`\n  (contact missing, merged or from another account), `event` or\n  `edition`.\n- `422 business_rule_violation` with `details.rule`:\n  `event_not_published` (draft), `event_closed` (event\n  archived, edition cancelled, or no upcoming edition),\n  `event_edition_ended` (edition ended).\n- `400 validation_error`: invalid body, or `event_id` and\n  `event_slug` both present or both missing.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EventRegistrationCreate"
              },
              "example": {
                "contact_id": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                "event_slug": "defi-5-jours",
                "language": "fr"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The contact was already registered for this edition; the same registration is returned.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/EventRegistration"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
                    "event_id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d",
                    "edition_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                    "contact_id": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                    "language": "fr",
                    "created": false
                  }
                }
              }
            }
          },
          "201": {
            "description": "Registration created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/EventRegistration"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
                    "event_id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d",
                    "edition_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                    "contact_id": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                    "language": "fr",
                    "created": true
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/v1/contacts": {
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "listContacts",
        "summary": "List contacts",
        "description": "Returns the paginated list of the account's contacts (cursor v2).\nStable sort `created_at DESC, id DESC`. Supports case-insensitive\nsearch on `email`, `first_name`, `last_name`, `company`, filtering\nby tag, and optional inclusion of the custom field definitions in\n`meta`.\n\n### Merged contacts\n\nA contact absorbed by a merge no longer appears in the list: its\nhistory lives on the surviving contact, which is the only one listed.\n\n### Search\n\nThe `?search` parameter is split into words (separated by spaces).\nEach word must appear, case-insensitively, in at least one of the\n`email`, `first_name`, `last_name`, or `company` columns, and the\nwords are combined with AND, in any order:\n`?search=Jean Bouchard` finds the contact whose first name is \"Jean\"\nand whose last name is \"Bouchard\". Each word is sent as a quoted\nliteral value: commas, parentheses, and periods don't alter the\nfilter.\n\n### Including custom fields\n\nPass `?include=custom_field_definitions` to add the\n`meta.custom_field_definitions` field to the response — useful for\nrendering an edit form on your side without a second round-trip.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "Word search across email, first_name, last_name, company (case-insensitive; each word must match a column, words combined with AND)."
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "UUID of a tag: returns only the contacts that carry this tag,\npaginated like the full list. To\ncount a campaign's leads, pass the `id` of its\n\"Campagne : <name>\" tag (`system_key` starting with `campaign:`\nin `GET /v1/tags`) and walk the pages (`next_cursor`)\nuntil `has_more: false`: `pagination.total_count` is only an\nestimate. A value that isn't a UUID is\nrejected (`400 invalid_request`, `details.field = \"tag\"`).\n"
          },
          {
            "name": "include",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "custom_field_definitions"
              ]
            },
            "description": "Includes the `meta.custom_field_definitions` block in the response."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of contacts.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Contact"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    },
                    "meta": {
                      "type": "object",
                      "description": "Additional block returned only with `?include=custom_field_definitions`.",
                      "properties": {
                        "custom_field_definitions": {
                          "type": "array",
                          "description": "The account's custom field definitions (label, type, options).",
                          "items": {
                            "type": "object",
                            "additionalProperties": true
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "email": "alex@example.com",
                      "phone": "+15145551234",
                      "first_name": "Alex",
                      "last_name": "Tremblay",
                      "company": "Acme Inc.",
                      "source": "api",
                      "pipeline_stage_id": null,
                      "deal_value": 2500,
                      "is_priority": false,
                      "custom_fields": null,
                      "created_at": "2026-04-01T10:00:00.000Z",
                      "updated_at": "2026-04-01T10:00:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "createContact",
        "summary": "Create a contact",
        "description": "Creates a contact under the API key's account, through the same path as the\ndashboard. An **email or a phone number** is required\n(either one is enough). The email is stored in lowercase; the\nphone number is normalized to E.164 when it's a valid North American\nnumber.\n\nThe person is looked up across all of the account's identities\n(primary and secondary emails and phone numbers, case-insensitive).\nIf they already exist, nothing is created: the response is\n`409 duplicate_contact`, with the existing contact in `data` and the\nmatching fields in `details.conflicting_fields`. If the email and\nthe phone number belong to two different contacts, the response is\n`409 duplicate_resource`, with no contact.\n\n### Source and consent\n\nThe created contact has the source `api`. An integration doesn't name\nanyone who actually met this contact: **no implied\nconsent** (SMS or email) is recorded when it's created. Capturia's SMS\nfollow-ups and prospecting emails are only sent to it\nonce a real basis for consent exists\n(form, booking, inbound message, attestation in\nCapturia). To capture a lead who just filled out your form,\nwith their consent, use `POST /v1/leads/capture`.\n\n### Idempotency\n\nThe `Idempotency-Key` header is **optional but recommended**.\nA second request with the same key and the same payload returns the\noriginal response without repeating the operation. A second request with\nthe same key and a different payload returns `409 idempotency_conflict`.\n\n### Custom fields\n\nThe keys of `custom_fields` are the field's **name** as defined\nin Capturia (or, equivalently, the id of its definition) —\nthe same contract as `/v1/events` and `/v1/leads/capture`. The values\nare validated against the account's definitions\n(`client_custom_field_definitions`). An unknown field or an\nincompatible type returns `400 invalid_custom_fields` with `details`\nlisting the violations.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactCreate"
              },
              "example": {
                "email": "alex@example.com",
                "first_name": "Alex",
                "last_name": "Tremblay",
                "phone": "+15145551234",
                "company": "Acme Inc.",
                "deal_value": 2500
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Contact created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Contact"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "company": "Acme Inc.",
                    "source": "api",
                    "pipeline_stage_id": null,
                    "deal_value": 2500,
                    "is_priority": false,
                    "custom_fields": null,
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "409": {
            "$ref": "#/components/responses/DuplicateResource"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "getContact",
        "summary": "Retrieve a contact",
        "description": "Returns a contact by its ID. The `?expand=tags` parameter joins the\n`client_contact_tags` table to include the attached tags (id, name,\ncolor, system_key) without an extra round-trip.\n\nThe ID of a contact absorbed by a merge returns `404`: reads, updates,\nand deletes go through the surviving contact. If the merge is undone\nfrom the dashboard, the contact becomes reachable again under its ID.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "expand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "tags"
              ]
            },
            "description": "If `tags`, adds `client_contact_tags` to the returned contact."
          }
        ],
        "responses": {
          "200": {
            "description": "Contact found.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "anyOf": [
                        {
                          "$ref": "#/components/schemas/Contact"
                        },
                        {
                          "$ref": "#/components/schemas/ContactWithTags"
                        }
                      ],
                      "description": "Contact, enriched with its tags when `?expand=tags` is passed.\n`ContactWithTags` extends `Contact` via `allOf` — a payload\nwithout tags satisfies both schemas (hence `anyOf`).\n"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "company": "Acme Inc.",
                    "source": "api",
                    "pipeline_stage_id": null,
                    "deal_value": 2500,
                    "is_priority": false,
                    "custom_fields": null,
                    "created_at": "2026-04-01T10:00:00.000Z",
                    "updated_at": "2026-04-01T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Contacts"
        ],
        "operationId": "updateContact",
        "summary": "Update a contact",
        "description": "Updates the provided fields of the contact (omitted fields are kept).\nYou must provide at least one field, otherwise `400 no_fields`.\n\nThe `custom_fields` field is **merged** with the existing values\n(keys you don't provide are kept). The keys are the field's **name**\n(or the id of its definition); an unknown key returns\n`400 invalid_custom_fields`. Passing `null` clears all custom\nfields.\n\nPhone number and email are unique per account: a value already\nheld by another contact (as a primary or secondary\ncontact detail) returns `409 duplicate_resource`\n(`details.conflicting_field` names the field) and the contact isn't\nmodified. The phone number is compared once normalized to E.164.\n\nThe update goes through the same service as the contact in the\ndashboard: same validations (email, phone number, positive amounts,\neach custom field's value according to its type), same entry in\nthe account's timeline and activity log, and the same\n`lead_updated` webhook. An invalid value returns\n`400 validation_error` (`details[].field` names the field); a\nphone number that can't be dialed (that doesn't normalize to\nE.164) is rejected with `details[].reason` = `invalid_phone`.\n\nA contact always keeps at least a first name, a last name, an email, or\na phone number: a request that would clear the last one returns\n`400 validation_error` (`details[].reason` = `identity_required`), and\nthe contact isn't modified.\n\nThe value (`deal_value`) of an archived contact can't be changed:\n`422 business_rule_violation` (`details.reason` =\n`contact_archived`), and no field from the request is written.\nRestore the contact from the dashboard first.\n\nThe `notes` field is the contact's free-form text, replaced on every\nwrite. To add a note to the contact's timeline, use\n`POST /v1/contacts/{id}/notes`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactUpdate"
              },
              "example": {
                "first_name": "Alexandre",
                "deal_value": 5000,
                "is_priority": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Contact updated.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Contact"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/DuplicateResource"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Contacts"
        ],
        "operationId": "deleteContact",
        "summary": "Delete a contact",
        "description": "Permanently deletes the contact, exactly as from the\ndashboard: its inbox conversation, its exchanges (SMS, emails,\nchat transcripts), its bookings, and its files go with\nit. Its voicemails and media received by SMS go too, as\ndo the audio of its calls and meetings and the word-for-word\ntranscripts of its calls, including at our telephony\nprovider: this removal is durable and retried automatically if the\nprovider is temporarily unreachable. Its call log\n(summary, notes, outcome) remains, detached from the contact, as do\nsales, orders, and payments.\n\nRemoval outside the platform (bookings in the connected Google calendar,\nstored files) happens right after, on a best-effort basis: the\ncontact's deletion from the database succeeds even if an external service is\ntemporarily unreachable. The response's `cleanup` object reports it —\n`failed` > 0 means some Google bookings couldn't be\nremoved from the calendar (account disconnected, outage) and remain there.\n\nRequires the `contacts:delete` scope, separate from writing: as in\nthe dashboard, deleting a contact is a separate permission.\nOnly someone who can delete leads in Capturia\ncan grant it to an API key.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:delete"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Contact deleted.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "id",
                        "deleted",
                        "cleanup"
                      ],
                      "properties": {
                        "id": {
                          "type": "string",
                          "format": "uuid",
                          "description": "ID of the deleted contact."
                        },
                        "deleted": {
                          "type": "boolean",
                          "description": "Always `true` when the 200 is returned."
                        },
                        "cleanup": {
                          "type": "object",
                          "description": "Result of removing bookings from the connected Google calendar (best-effort). `failed` > 0 = some bookings couldn't be removed and remain in the calendar.",
                          "required": [
                            "attempted",
                            "succeeded",
                            "failed"
                          ],
                          "properties": {
                            "attempted": {
                              "type": "integer",
                              "description": "Google bookings targeted by the removal."
                            },
                            "succeeded": {
                              "type": "integer",
                              "description": "Bookings removed from the calendar."
                            },
                            "failed": {
                              "type": "integer",
                              "description": "Bookings left in the calendar after a failure."
                            }
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "deleted": true,
                    "cleanup": {
                      "attempted": 0,
                      "succeeded": 0,
                      "failed": 0
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}/timeline": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "getContactTimeline",
        "summary": "Contact timeline (chronological activity)",
        "description": "Returns the contact's activity timeline (stage changes,\nSMS/email sends, bookings, etc.) in reverse chronological order.\n\n### Current limitation\n\nThe implementation is a stub: the `pipeline_history` table references\nthe other, internal `contacts` table (not `client_contacts`), so\nthe returned list is **always empty** today. Pagination\nis validated but not applied. To be implemented in a later\nphase once the history is unified on `client_contacts`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Empty list (stub implementation) with valid pagination.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Always empty today (stub).",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 0
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}/tags": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "attachContactTags",
        "summary": "Attach one or more tags to a contact",
        "description": "Attaches one or more tags to the contact through the\n`client_contact_tags` join table. The tags must belong to the same account\nas the contact — otherwise `400 validation_error` with the list of\ntags not found. An identifier that isn't a UUID also returns\n`400 validation_error`.\n\nThe operation is idempotent at the database level: calling it twice with the\nsame `tag_ids` doesn't create duplicates (upsert\n`ON CONFLICT (contact_id, tag_id) DO NOTHING`).\n\n### Campaign tags\n\nA tag whose `system_key` starts with `campaign:` is managed by\nCapturia: it follows the lead's campaign. If the contact already carries it, it\nis ignored (sending back a lead's list of tags stays idempotent).\nIf the contact doesn't carry it, it returns `409 tag_managed_by_capturia` and\nnone of the request's tags are attached.\n\n### Listing a contact's tags\n\nThere's no dedicated `GET` — use\n`GET /v1/contacts/{id}?expand=tags` to retrieve the tags\ncurrently attached.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tag_ids"
                ],
                "additionalProperties": false,
                "properties": {
                  "tag_ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "UUIDs of the tags to attach (all must belong to the same account, at most 100 per request).",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              },
              "example": {
                "tag_ids": [
                  "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                  "9a4c2b3d-0e5f-5a6b-c7d8-e9f0a1b2c3d4"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tags attached. Returns the contact's up-to-date list of tags.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "contact_id",
                        "tags"
                      ],
                      "properties": {
                        "contact_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "tags": {
                          "type": "array",
                          "description": "The contact's up-to-date list of tags (all of them, not just the new ones).",
                          "items": {
                            "type": "object",
                            "required": [
                              "tag_id",
                              "client_tags"
                            ],
                            "properties": {
                              "tag_id": {
                                "type": "string",
                                "format": "uuid"
                              },
                              "client_tags": {
                                "type": "object",
                                "required": [
                                  "id",
                                  "name",
                                  "color"
                                ],
                                "properties": {
                                  "id": {
                                    "type": "string",
                                    "format": "uuid"
                                  },
                                  "name": {
                                    "type": "string",
                                    "example": "Lead chaud"
                                  },
                                  "color": {
                                    "type": "string",
                                    "description": "Hexadecimal color of the tag (`#` prefix).",
                                    "example": "#ef4444"
                                  },
                                  "system_key": {
                                    "type": [
                                      "string",
                                      "null"
                                    ],
                                    "description": "Stable key of a tag managed by Capturia (`campaign:…`\nfor a campaign's tag), `null` for an account tag.\n"
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "tags": [
                      {
                        "tag_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                        "client_tags": {
                          "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                          "name": "Lead chaud",
                          "color": "#ef4444",
                          "system_key": null
                        }
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/TagManagedByCapturia"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}/tags/{tagId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        },
        {
          "name": "tagId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "UUID of the tag to detach."
        }
      ],
      "delete": {
        "tags": [
          "Contacts"
        ],
        "operationId": "detachContactTag",
        "summary": "Detach a tag from a contact",
        "description": "Removes the association between a contact and a tag. The tag itself\nisn't deleted — it remains available for other contacts.\n\nIf the tag isn't attached to the contact (or if the contact belongs\nto another account), returns `404 resource_not_found`.\n\nA campaign tag (`system_key` starting with `campaign:`) follows\nthe lead's campaign: detaching it returns\n`409 tag_managed_by_capturia`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Tag detached.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "contact_id",
                        "tag_id",
                        "removed"
                      ],
                      "properties": {
                        "contact_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "tag_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "removed": {
                          "type": "boolean",
                          "description": "Always `true` when the 200 is returned."
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "tag_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "removed": true
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/TagManagedByCapturia"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}/notes": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "addContactNote",
        "summary": "Add a note to a contact's timeline",
        "description": "Adds a note to the contact's timeline, exactly as from\nthe contact in the dashboard: it's added to the previous notes,\nappears in the contact's Notes tab, and feeds the lead's\nqualification. The note is signed by the API key that wrote it.\n\nNot to be confused with the contact's free-form text field `notes`\n(`PATCH /v1/contacts/{id}`), replaced on every write.\n\nThe content is required and is at most 20,000 characters; otherwise\n`400 validation_error`. A contact that's unknown, belongs to another account, or\nwas merged into another contact returns `404`.\n\n### Idempotency\n\nThe `Idempotency-Key` header is **optional but recommended**: a\nsecond request with the same key, the same contact, and the same content\nreturns the original note without writing a second one. The same key\nwith another contact or other content returns\n`409 idempotency_conflict`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "content"
                ],
                "additionalProperties": false,
                "properties": {
                  "content": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 20000,
                    "description": "Text of the note."
                  }
                }
              },
              "example": {
                "content": "Appel de suivi : le client veut une soumission pour 3 succursales."
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Note added to the contact's timeline.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "id",
                        "contact_id",
                        "content",
                        "created_at"
                      ],
                      "properties": {
                        "id": {
                          "type": "string",
                          "format": "uuid",
                          "description": "ID of the note."
                        },
                        "contact_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "content": {
                          "type": "string"
                        },
                        "created_at": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
                    "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "content": "Appel de suivi : le client veut une soumission pour 3 succursales.",
                    "created_at": "2026-10-06T14:30:00Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/batch": {
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "createContactsBatch",
        "summary": "Create or update multiple contacts in one request",
        "description": "Atomic upsert of up to 100 contacts in a single request.\nContacts whose email already exists under the account are **updated**\n(the other fields you provide replace the existing values), the\nrest are **inserted**.\n\n### Internal deduplication\n\nThe payload is deduplicated internally by `lower(email)` (last entry\nwins) before the upsert. The order of the contacts in the array and the\ncase of the email don't affect the idempotency hash.\n\n### Idempotency\n\nThe `Idempotency-Key` header is **optional but recommended**. The\nidempotency hash covers the payload **after internal deduplication** —\na network retry stays idempotent even if you resend the contacts in\na different order or with different email casing.\n\n### Response\n\nThe endpoint returns `200` with `{data: {processed, contacts: []}}`\n— no per-contact multi-status. A validation error on a\nsingle contact rejects the whole batch (`400 validation_error`\nwith `details` listing each faulty row). A database error\nduring the upsert returns `500` — contacts already persisted\nbefore the error remain (no transactional rollback\nacross statements).\n\nWhen `system_id` is provided and resolves to an active System, the\nresponse also includes `system_routing`: the summary of routing the\n**created** contacts to that System. Routing is best-effort\n(the upsert is already committed when it runs) — `refused + errors` =\ncreated contacts that stayed in the main System.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contacts"
                ],
                "additionalProperties": false,
                "properties": {
                  "contacts": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "List of contacts to upsert (max 100 per request, otherwise `400 batch_too_large`).",
                    "items": {
                      "$ref": "#/components/schemas/ContactBatchItem"
                    }
                  },
                  "system_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Target System of the batch (UUID of one of the account's Systems). The\ncontacts **created** by this batch are engaged in this\nSystem; updated contacts keep their engagement.\nOmitted → main System (historical behavior). UUID\nunknown for this account → `system_not_found` (422), no\ncontact written; archived System → silent fallback to the\nmain System.\n",
                    "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
                  },
                  "trigger_automations": {
                    "type": "boolean",
                    "default": false,
                    "description": "Triggers the account's active automations and sequences\nfor the contacts in this batch (contact created, tags\nadded). **Default `false`: nothing is triggered** —\nno automation or sequence enrollment, no\nemail or SMS caused by this import. When `true`, the response\nreturns `automations_enrolled` and, if the engine's hourly\ncap (200 enrollments/account/hour) is reached,\n`automations_skipped_rate_limit` — never a silent\ndrop.\n"
                  }
                }
              },
              "example": {
                "contacts": [
                  {
                    "email": "alex@example.com",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "phone": "+15145551234",
                    "company": "Acme Inc.",
                    "deal_value": 2500
                  },
                  {
                    "email": "morgan@example.com",
                    "first_name": "Morgan",
                    "last_name": "Bélanger",
                    "company": "Example Inc."
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Batch processed. `processed` = number of contacts successfully inserted or updated.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "required": [
                        "processed",
                        "contacts"
                      ],
                      "properties": {
                        "processed": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Total number of contacts processed (inserted + updated)."
                        },
                        "automations_enrolled": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Automation enrollments created by this batch.\nAlways `0` when `trigger_automations` is omitted\nor `false`.\n"
                        },
                        "automations_skipped_rate_limit": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Enrollments refused because the automation engine's hourly\ncap (200 enrollments/account/\nhour) was reached. `> 0` = part of the batch didn't\nenter the automations — re-import these\ncontacts later or enroll them manually.\n"
                        },
                        "sequences_enrolled": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Email sequence enrollments created by this batch.\nAlways `0` when `trigger_automations` is omitted\nor `false`.\n"
                        },
                        "contacts": {
                          "type": "array",
                          "description": "Persisted contact rows: main fields and\nlocation (same fields as `Contact`), without\n`custom_fields`, `source`, or `pipeline_stage_id`.\n",
                          "items": {
                            "type": "object",
                            "required": [
                              "id",
                              "email",
                              "created_at",
                              "updated_at"
                            ],
                            "properties": {
                              "id": {
                                "type": "string",
                                "format": "uuid"
                              },
                              "email": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "email"
                              },
                              "first_name": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "last_name": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "phone": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "company": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "deal_value": {
                                "type": [
                                  "number",
                                  "null"
                                ]
                              },
                              "address": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "description": "Full address as entered or validated."
                              },
                              "street": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "city": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "sector": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "region": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "description": "Administrative region, derived from the city (Québec)."
                              },
                              "province": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "country": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "postal_code": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "created_at": {
                                "$ref": "#/components/schemas/Timestamp"
                              },
                              "updated_at": {
                                "$ref": "#/components/schemas/Timestamp"
                              }
                            }
                          }
                        },
                        "system_routing": {
                          "type": "object",
                          "description": "Summary of routing the **created** contacts to the\ntarget System. Present only when `system_id` was\nprovided and resolved to an active System.\nRouting is best-effort: `refused + errors` =\ncreated contacts that stayed in the main System.\n",
                          "required": [
                            "requested",
                            "routed",
                            "noop",
                            "refused",
                            "errors"
                          ],
                          "properties": {
                            "requested": {
                              "type": "integer",
                              "minimum": 0,
                              "description": "Created contacts submitted for routing."
                            },
                            "routed": {
                              "type": "integer",
                              "minimum": 0,
                              "description": "Contacts engaged in the target System."
                            },
                            "noop": {
                              "type": "integer",
                              "minimum": 0,
                              "description": "Contacts already engaged in the target System — nothing to do."
                            },
                            "refused": {
                              "type": "integer",
                              "minimum": 0,
                              "description": "Refused by the traffic director (upcoming booking, open proposal…) — stayed in the main System."
                            },
                            "errors": {
                              "type": "integer",
                              "minimum": 0,
                              "description": "Technical failures (contact in error or failed routing call) — stayed in the main System."
                            }
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "data": {
                    "processed": 2,
                    "automations_enrolled": 0,
                    "automations_skipped_rate_limit": 0,
                    "sequences_enrolled": 0,
                    "contacts": [
                      {
                        "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                        "email": "alex@example.com",
                        "first_name": "Alex",
                        "last_name": "Tremblay",
                        "phone": "+15145551234",
                        "company": "Acme Inc.",
                        "deal_value": 2500,
                        "address": "73 rue Bérard, Gatineau, QC J8T 5R8",
                        "street": "73 rue Bérard",
                        "city": "Gatineau",
                        "sector": null,
                        "region": "Outaouais",
                        "province": "QC",
                        "country": "Canada",
                        "postal_code": "J8T 5R8",
                        "created_at": "2026-04-01T10:00:00.000Z",
                        "updated_at": "2026-05-04T10:00:00.000Z"
                      },
                      {
                        "id": "9a4c2b3d-0e5f-5a6b-c7d8-e9f0a1b2c3d4",
                        "email": "morgan@example.com",
                        "first_name": "Morgan",
                        "last_name": "Bélanger",
                        "phone": null,
                        "company": "Example Inc.",
                        "deal_value": null,
                        "address": null,
                        "street": null,
                        "city": null,
                        "sector": null,
                        "region": null,
                        "province": null,
                        "country": null,
                        "postal_code": null,
                        "created_at": "2026-05-04T10:00:00.000Z",
                        "updated_at": "2026-05-04T10:00:00.000Z"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/conversations": {
      "get": {
        "tags": [
          "Conversations"
        ],
        "operationId": "listConversations",
        "summary": "List conversations",
        "description": "Returns a paginated list of the account's conversations (chat threads)\n(cursor v2). Stable sort: `created_at DESC, id DESC`.\n\nA conversation groups a visitor's messages with an agent\n(AI or human) under the calling account. Threads are created and\nmessages are sent from the front end / chat SDK — the public\nAPI is read-only.\n\n### Filters\n\n- `?agent_slug` — exact match on the AI agent replying in the thread.\n- `?agent_identity_id` — exact match on the thread's specific AI agent\n  (an account can have several chatbots).\n- `?source` — exact match on the thread source (URL, funnel\n  label, `widget`, etc.).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "conversations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "agent_slug",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "AI agent slug — exact match on the threads this agent serves."
          },
          {
            "name": "agent_identity_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Identifier of the specific AI agent — exact match on the threads it serves."
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Thread source — exact match (URL, funnel label, `widget`, etc.)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of conversations.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Conversation"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "first_name": "Alex",
                      "last_name": "Tremblay",
                      "email": "alex@example.com",
                      "phone": "+15145551234",
                      "agent_slug": "support-ventes",
                      "agent_identity_id": "2c7d1e4a-5b6f-4a8c-9d0e-1f2a3b4c5d6e",
                      "source": "widget",
                      "message_count": 12,
                      "last_message_at": "2026-05-04T12:34:56.789Z",
                      "contact_id": null,
                      "created_at": "2026-05-04T10:00:00.000Z",
                      "updated_at": "2026-05-04T12:34:56.789Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/conversations/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "get": {
        "tags": [
          "Conversations"
        ],
        "operationId": "getConversation",
        "summary": "Retrieve a conversation",
        "description": "Returns a conversation's metadata by its ID. To\nretrieve the individual messages, use\n`GET /v1/conversations/{id}/messages`.\n\nIf the conversation belongs to another account (or doesn't exist),\nreturns `404 resource_not_found` — tenant isolation hides\nthe existence of conversations outside the calling account.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "conversations:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Conversation found.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Conversation"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "agent_slug": "support-ventes",
                    "source": "widget",
                    "message_count": 12,
                    "last_message_at": "2026-05-04T12:34:56.789Z",
                    "contact_id": null,
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T12:34:56.789Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/conversations/{id}/messages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "get": {
        "tags": [
          "Conversations"
        ],
        "operationId": "listConversationMessages",
        "summary": "List a conversation's messages",
        "description": "Returns a paginated list of a conversation's messages in\n**chronological reading order** (`created_at ASC, id ASC`),\nwith an `id` tiebreaker to prevent duplicates or skipped rows between\ntwo pages when several messages share the same millisecond.\n\nThis sort direction (ASC) is the reverse of the other\nv2 list endpoints (DESC) — a conversation reads naturally\nin the chronological order of the exchange.\n\nEach message tells you who wrote it (`author_kind`): the visitor,\nthe AI agent, or a sales rep on the team who replied from the Inbox\n(`author_name` then holds their first name).\n\nIf the conversation belongs to another account (or doesn't exist),\nreturns `404 resource_not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "conversations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of messages (chronological order).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "role": "user",
                      "content": "Bonjour, j'aimerais en savoir plus sur vos services.",
                      "created_at": "2026-05-04T10:00:00.000Z",
                      "author_kind": "visitor",
                      "author_name": null
                    },
                    {
                      "id": "9a4c2b3d-0e5f-5a6b-c7d8-e9f0a1b2c3d4",
                      "role": "assistant",
                      "content": "Bonjour Alex ! Avec plaisir — quel besoin cherchez-vous à résoudre ?",
                      "created_at": "2026-05-04T10:00:05.000Z",
                      "author_kind": "agent",
                      "author_name": null
                    },
                    {
                      "id": "0b5d3c4e-1f6a-4b7c-8d9e-f0a1b2c3d4e5",
                      "role": "assistant",
                      "content": "Bonjour Alex, ici Marie de l'équipe. Je vous appelle demain ?",
                      "created_at": "2026-05-04T10:12:00.000Z",
                      "author_kind": "human",
                      "author_name": "Marie"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 3
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/bookings": {
      "get": {
        "tags": [
          "Bookings"
        ],
        "operationId": "listBookings",
        "summary": "List bookings in the schedule",
        "description": "Returns the paginated list (cursor v2) of bookings in the schedule of\nthe key's account: all its calendars, all its hosts. Bookings\narchived from the dashboard are excluded. Stable sort\n`start_datetime DESC, id DESC` — the furthest-out bookings first,\nthen the history.\n\n### Filters\n\n- `?from` (ISO 8601) — `start_datetime >= from`\n- `?to` (ISO 8601) — `start_datetime <= to`\n- `?status` — `confirmed`, `cancelled`, `completed`, or `no_show`\n- `?event_type_id` — a calendar of the account\n- `?contact_id` — a contact of the account\n\nAn unreadable filter is rejected with `400 validation_error`, with the\nfield and the reason (`invalid_datetime`, `invalid_value`,\n`invalid_format`); an invalid cursor or limit, `400 invalid_request`.\nAn `event_type_id` or a `contact_id` from another account returns an\nempty list.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "bookings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Only bookings where `start_datetime >= from`."
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Only bookings where `start_datetime <= to`."
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "confirmed",
                "cancelled",
                "completed",
                "no_show"
              ]
            },
            "description": "Exact match on status."
          },
          {
            "name": "event_type_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Only bookings on this calendar."
          },
          {
            "name": "contact_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Only bookings for this contact."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of bookings.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Booking"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "event_type_id": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e",
                      "contact_id": "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d",
                      "host_id": "5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f",
                      "guest_name": "Alex Tremblay",
                      "guest_email": "alex@example.com",
                      "guest_phone": "+15145551234",
                      "start_datetime": "2026-11-10T15:00:00+00:00",
                      "end_datetime": "2026-11-10T15:30:00+00:00",
                      "timezone": "America/Montreal",
                      "status": "confirmed",
                      "source": "booking_page",
                      "notes": null,
                      "meeting_link": "https://meet.google.com/abc-defg-hij",
                      "cancelled_at": null,
                      "cancel_reason": null,
                      "created_at": "2026-11-04T10:00:00+00:00",
                      "updated_at": null
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Bookings"
        ],
        "operationId": "createBooking",
        "summary": "Book an appointment",
        "description": "Books an appointment in the account's schedule, on one of its\ncalendars, through the same service as the booking page and the\ndashboard. The calendar's rules apply: its active hosts, their hours\nand their Google Calendar, the booking window, the slot cadence, the\ndaily maximum, group sessions, required questions, the access rule,\nand a single upcoming booking per contact on this calendar. The\nrequested time is honored or rejected with its reason — never\nreplaced with another. The duration is the calendar's; the host\nreceives their notification and the event is added to their calendar\nas for any booking.\n\nEach successful call creates a booking: don't replay a call that\nsucceeded.\n\n### Rejections\n\nThe `details.reason` field states why, in a stable way:\n\n| Status | `code` | `details.reason` |\n|---|---|---|\n| 400 | `validation_error` | `slot_in_past`, `outside_window`, `off_cadence` (`details.field = \"start_datetime\"`), `contact_or_guest_required`, `contact_without_email`, or the code of the failing question (`details.field = \"form_responses.<id>\"`, `details.questions`: the calendar's questions) |\n| 404 | `resource_not_found` | — (`details.field`: `event_type_id`, `contact_id`, or `host_id`) |\n| 409 | `duplicate_resource` | `already_booked` (`details.existing_booking_id`) |\n| 422 | `business_rule_violation` | `calendar_without_hosts`, `host_not_on_calendar`, `host_unavailable` (`details.causes`), `slot_unavailable`, `contact_archived`, `lead_not_qualified` |\n| 503 | `service_unavailable` | — (retry after `Retry-After`) |\n\nA field format error (`400 validation_error`) carries `details` as a\nlist `[{ field, reason }]`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "bookings:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingCreate"
              },
              "examples": {
                "contact": {
                  "summary": "An existing contact of the account",
                  "value": {
                    "event_type_id": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e",
                    "start_datetime": "2026-11-10T10:00:00-05:00",
                    "contact_id": "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d",
                    "notes": "Premier rendez-vous découverte"
                  }
                },
                "guest": {
                  "summary": "A guest, found or created by their contact details",
                  "value": {
                    "event_type_id": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e",
                    "start_datetime": "2026-11-10T10:00:00-05:00",
                    "guest_name": "Alex Tremblay",
                    "guest_email": "alex@example.com",
                    "guest_phone": "+15145551234",
                    "timezone": "America/Montreal",
                    "host_id": "5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f",
                    "form_responses": {
                      "budget": {
                        "value": "10 000 $ à 25 000 $"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booking created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Booking"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "event_type_id": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e",
                    "contact_id": "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d",
                    "host_id": "5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f",
                    "guest_name": "Alex Tremblay",
                    "guest_email": "alex@example.com",
                    "guest_phone": "+15145551234",
                    "start_datetime": "2026-11-10T15:00:00+00:00",
                    "end_datetime": "2026-11-10T15:30:00+00:00",
                    "timezone": "America/Montreal",
                    "status": "confirmed",
                    "source": "api",
                    "notes": "Premier rendez-vous découverte",
                    "meeting_link": "https://meet.google.com/abc-defg-hij",
                    "cancelled_at": null,
                    "cancel_reason": null,
                    "created_at": "2026-11-04T10:00:00+00:00",
                    "updated_at": null
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "description": "The lead already has an upcoming booking on this calendar\n(`duplicate_resource`, reason `already_booked`, with\n`details.existing_booking_id`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  }
                },
                "example": {
                  "error": {
                    "type": "conflict_error",
                    "code": "duplicate_resource",
                    "message": "Une ressource avec cette valeur unique existe déjà.",
                    "doc_url": "https://capturia.io/fr/developers/api/v1/errors#duplicate_resource",
                    "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                    "details": {
                      "reason": "already_booked",
                      "existing_booking_id": "7d8e9f0a-1b2c-4d4e-8f6a-7b8c9d0e1f2a"
                    }
                  }
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "422": {
            "description": "The requested time or host can't be honored: calendar without an\nactive host (`calendar_without_hosts`), host who isn't a host of this\ncalendar (`host_not_on_calendar`), host unavailable\n(`host_unavailable`, causes in `details.causes`:\n`outside_window`, `outside_hours`, `other_booking`,\n`blocked_time`, `google_busy`, `daily_max`), slot taken or\nfull (`slot_unavailable`), archived contact (`contact_archived`),\nlead excluded by the calendar's access rule or disqualified\n(`lead_not_qualified`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  }
                },
                "example": {
                  "error": {
                    "type": "business_rule_error",
                    "code": "business_rule_violation",
                    "message": "Une règle métier empêche cette opération.",
                    "doc_url": "https://capturia.io/fr/developers/api/v1/errors#business_rule_violation",
                    "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                    "details": {
                      "reason": "host_unavailable",
                      "causes": [
                        "google_busy"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/v1/bookings/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "delete": {
        "tags": [
          "Bookings"
        ],
        "operationId": "cancelBooking",
        "summary": "Cancel a booking",
        "description": "**Soft cancel** — the booking is not deleted: its `status` changes\nto `cancelled` and `cancelled_at` is timestamped, like a\ncancellation made in the dashboard. The event is removed from the\nhost's calendar, the host is notified, and the cancellation is\nrecorded as made by one of your team's integrations. The `200`\nresponse returns these 3 fields (not `204 No Content`).\n\nOnly a `confirmed` booking can be cancelled. A booking that is\nalready cancelled returns `422 business_rule_violation` (reason\n`already_cancelled`); a booking that was held or marked as a no-show,\n`422` (reason `booking_not_cancellable`). A booking from another\naccount is not found (`404`).\n\nTo change the time, cancel and book again, or reschedule from the\ndashboard — there is no `PATCH`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "bookings:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Booking cancelled. Returns the cancellation status and timestamp.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BookingCancelResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "status": "cancelled",
                    "cancelled_at": "2026-11-04T12:00:00+00:00"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "422": {
            "description": "The booking is already cancelled (`already_cancelled`), or it was\nheld or marked as a no-show (`booking_not_cancellable`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "$ref": "#/components/schemas/ErrorEnvelope"
                    }
                  }
                },
                "example": {
                  "error": {
                    "type": "business_rule_error",
                    "code": "business_rule_violation",
                    "message": "Une règle métier empêche cette opération.",
                    "doc_url": "https://capturia.io/fr/developers/api/v1/errors#business_rule_violation",
                    "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                    "details": {
                      "reason": "already_cancelled"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/quotes": {
      "get": {
        "tags": [
          "Quotes"
        ],
        "operationId": "listQuotes",
        "summary": "List quotes",
        "description": "Returns the paginated list of the account's quotes (cursor v2). Stable sort\n`created_at DESC, id DESC`.\n\nArchived quotes (`archived_at IS NOT NULL`) are excluded by\ndefault — the public API does not expose them. To retrieve an archived\nquote, use the dashboard.\n\n### Filters\n\n- `?status` — exact match (`draft`, `sent`, `signed`, `cancelled`)\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "signed",
                "cancelled"
              ]
            },
            "description": "Exact match on the quote lifecycle."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of quotes.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Quote"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "client_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                      "quote_number": "DEV-2026-0042",
                      "status": "draft",
                      "prospect_name": "Alex Tremblay",
                      "prospect_email": "alex@example.com",
                      "prospect_phone": "+15145551234",
                      "prospect_company": "Acme Inc.",
                      "prospect_address": null,
                      "subtotal": null,
                      "tps_amount": null,
                      "tvq_amount": null,
                      "total": null,
                      "valid_until": "2026-06-30",
                      "payment_terms": "Net 30",
                      "sent_at": null,
                      "first_viewed_at": null,
                      "signed_at": null,
                      "sales_rep_id": null,
                      "created_at": "2026-05-04T10:00:00.000Z",
                      "updated_at": "2026-05-04T10:00:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "createQuote",
        "summary": "Create a quote",
        "description": "Creates a draft quote (`status=\"draft\"`) through the same creation\npath as the dashboard: Capturia assigns the number\n(`quote_number`, the account's sequence, never a duplicate) and applies the\naccount's defaults (validity, payment terms, tax profile,\ndeposit policy). A provided `quote_number` is rejected\n(`400 validation_error`, reason `read_only`).\n\n`contact_id` links the quote to its contact (`/v1/contacts`): each\nomitted `prospect_*` field is read from the contact, and each field you provide\ntakes precedence. `line_items` writes the line items in the same operation; the\nserver computes the amounts. To send the quote to the\nlead, use `POST /v1/quotes/{id}/send`.\n\n### Idempotency\n\nThe `Idempotency-Key` header is **optional but recommended**: a\nretry after a timeout never creates a second quote. A\nsecond request with the same key and the same payload returns the original\nresponse; the same key with a different payload returns\n`409 idempotency_conflict`. A rejection (400, 403, 404) is not\nstored: the key remains usable.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteCreate"
              },
              "example": {
                "contact_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
                "valid_until": "2026-06-30",
                "payment_terms": "Net 30",
                "line_items": [
                  {
                    "description": "Remplacement de la toiture",
                    "quantity": 1,
                    "unit_price": 9500
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Quote created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Quote"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "client_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "quote_number": "DEV-2026-0042",
                    "status": "draft",
                    "prospect_name": "Alex Tremblay",
                    "prospect_email": "alex@example.com",
                    "prospect_phone": "+15145551234",
                    "prospect_company": "Acme Inc.",
                    "prospect_address": null,
                    "subtotal": null,
                    "tps_amount": null,
                    "tvq_amount": null,
                    "total": null,
                    "valid_until": "2026-06-30",
                    "payment_terms": "Net 30",
                    "sent_at": null,
                    "first_viewed_at": null,
                    "signed_at": null,
                    "sales_rep_id": null,
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/quotes/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "patch": {
        "tags": [
          "Quotes"
        ],
        "operationId": "updateQuote",
        "summary": "Update a quote",
        "description": "Updates the provided fields of the quote (omitted fields are kept).\nAt least one field must be provided, otherwise `400 no_fields`.\n\nAmounts (`subtotal`, `tps_amount`, `tvq_amount`, `total`) are\nnot editable via PATCH — they are computed server-side from the\nblocks (managed from the dashboard).\n\n`status` only accepts `declined` (the lead declined it) and\n`cancelled`: a quote moves to `sent` through `POST /v1/quotes/{id}/send`\n(the email goes out), to `viewed` and `signed` through the lead, and to\n`expired` through its validity date. A signed, paid, or already closed quote\ncan no longer be closed (`409 status_locked`). Closing must be done on its own:\n`status` combined with other fields returns `400 validation_error`\n(reason `exclusive_field`).\n\nArchived quotes (`archived_at IS NOT NULL`) cannot be\nmodified — returns `404 resource_not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteUpdate"
              },
              "example": {
                "prospect_name": "Alexandre Tremblay",
                "valid_until": "2026-07-31",
                "payment_terms": "Net 60"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quote updated.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Quote"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/quotes/{id}/send": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "post": {
        "tags": [
          "Quotes"
        ],
        "operationId": "sendQuote",
        "summary": "Send a quote to the lead",
        "description": "Emails the quote to the lead, through the same service as the\ndashboard's “Send” button: sent from the assigned sales rep (otherwise\nfrom the business), with a link to the quote's public page, then\n`status=\"sent\"` and `sent_at` are set. Only a draft can be sent; nothing\ngoes out again for a quote that was already sent.\n\nA quote without a contact (lead entered without a contact) is\nfirst linked to the lead's contact: found by their email or\nphone number, otherwise created — never a duplicate. The email then opens\nits thread in the Inbox. The created contact stays in the API key's account,\nand its creation triggers the automations and CRM sync\nof a new contact.\n\n### Rejections\n\n- `409 already_signed`: the quote is signed.\n- `409 quote_cancelled`: the quote was cancelled.\n- `409 already_sent`: the quote was already sent.\n- `409 quote_status_changed`: the quote is no longer a draft (for example, it expired).\n- `409 send_in_progress`: a send of this quote is already in progress.\n- `409 send_outcome_unknown`: a previous send could not be confirmed; it is not retried.\n- `409 recipient_unsubscribed`: the lead unsubscribed.\n- `409 sender_unavailable`: the assigned sales rep has no valid sending address.\n- `422 no_prospect_email` / `422 invalid_prospect_email`: the lead's email is missing or invalid.\n- `422 unpriced_lines`, `422 untaxed_quote`, `422 deposit_not_payable`: content guard.\n\nNo body is required.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:write"
            ]
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "description": "Optional body — no fields are used today (reserved for future use)."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Quote sent to the lead.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteSendResult"
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "client_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                    "quote_number": "DEV-2026-0042",
                    "status": "sent",
                    "prospect_name": "Alex Tremblay",
                    "prospect_email": "alex@example.com",
                    "sent_at": "2026-05-04T12:00:00.000Z",
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T12:00:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/tags": {
      "get": {
        "tags": [
          "Tags"
        ],
        "operationId": "listTags",
        "summary": "List tags",
        "description": "Returns the paginated list of the client's tags (cursor v2). Stable sort\n`name ASC, id ASC` — alphabetical order makes it easy to build\na selection UI. The `id ASC` tiebreaker guarantees a total order\nacross pages (names are unique within the account and case-sensitive).\n\n### Cursor sanitization\n\nSince `name` is user-controlled, the cursor encodes values with\nPostgREST escaping on the server — no action is required on your side.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "tags:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of tags.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Tag"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "name": "Lead chaud",
                      "color": "#ef4444",
                      "description": "Prospect avec budget confirmé et calendrier court.",
                      "category": "priority",
                      "system_key": null,
                      "created_at": "2026-05-04T10:00:00.000Z"
                    },
                    {
                      "id": "4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
                      "name": "Campagne : PPC",
                      "color": "#8b5cf6",
                      "description": null,
                      "category": null,
                      "system_key": "campaign:pn:7d1e2f3a-4b5c-4d6e-8f9a-0b1c2d3e4f5a",
                      "created_at": "2026-05-04T09:45:00.000Z"
                    },
                    {
                      "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                      "name": "Restauration",
                      "color": "#3b82f6",
                      "description": null,
                      "category": "industry",
                      "system_key": null,
                      "created_at": "2026-05-04T09:30:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 3
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Tags"
        ],
        "operationId": "createTag",
        "summary": "Create a tag",
        "description": "Creates a new tag in the client's library. Only `name` is\nrequired — `color` defaults to `#6b7280` (neutral\ngray) if not provided. To attach the tag to an existing contact,\nuse `POST /v1/contacts/{id}/tags` with the identifier returned\nby this endpoint.\n\nThe name is unique within the account: a name that is already taken returns\n`409 duplicate_resource`. A name that starts with \"Campagne :\"\n(any casing and spacing) is reserved for campaign tags managed by\nCapturia and returns `400 validation_error`, with\n`details: { field: \"name\", reason: \"reserved_prefix\" }`. A missing\n`name` returns `400 validation_error` with\n`details: { field: \"name\", reason: \"required\" }`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "tags:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagCreate"
              },
              "example": {
                "name": "Lead chaud",
                "color": "#ef4444",
                "description": "Prospect avec budget confirmé et calendrier court.",
                "category": "priority"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tag created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Tag"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "name": "Lead chaud",
                    "color": "#ef4444",
                    "description": "Prospect avec budget confirmé et calendrier court.",
                    "category": "priority",
                    "system_key": null,
                    "created_at": "2026-05-04T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "409": {
            "$ref": "#/components/responses/DuplicateResource"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/automations": {
      "get": {
        "tags": [
          "Automations"
        ],
        "operationId": "listAutomations",
        "summary": "List automations",
        "description": "Returns the paginated list of the client's active automations (cursor v2).\nStable sort `created_at DESC, id DESC`. Archived automations\n(`archived_at IS NOT NULL`) are excluded — the public API does not\nexpose them.\n\n### Filters\n\n- `?status=active` — only automations with `is_active=true`.\n- `?status=inactive` — only automations with `is_active=false`.\n\n### Read-only\n\nThe public API only exposes read access (list + history) and\nmanual triggering. Creating, editing and archiving an automation\nhappen in the dashboard — there is no POST/PATCH/DELETE\n`/v1/automations` endpoint.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "inactive"
              ]
            },
            "description": "Filter on `is_active=true` (active) or `is_active=false` (inactive)."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of automations.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Automation"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "name": "Relance lead inactif 7 jours",
                      "category": "nurturing",
                      "trigger_type": "lead_captured",
                      "action_type": "send_email",
                      "is_active": true,
                      "last_executed_at": "2026-05-04T08:12:00.000Z",
                      "execution_count": 42,
                      "created_at": "2026-04-15T10:00:00.000Z",
                      "updated_at": "2026-05-04T08:12:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/automations/{id}/trigger": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "post": {
        "tags": [
          "Automations"
        ],
        "operationId": "triggerAutomation",
        "summary": "Manually trigger an automation",
        "description": "Creates an execution with `status=\"pending\"` and `trigger_type=\"manual_api\"`,\nclaimable by the automation engine (asynchronous, no guarantee of\nsynchronous completion). To track the execution's progress,\ncall `GET /v1/automations/{id}/history`.\n\n### Preconditions\n\n- Automations must not have been removed from the account —\n  otherwise `403 feature_not_available`.\n- The automation must belong to the calling client and must not be\n  archived — otherwise `404 not_found`.\n- The automation must have `is_active=true` — otherwise\n  `409 automation_inactive`.\n- The automation must be published — otherwise `409 automation_not_published`.\n- The published automation must have a trigger node — otherwise\n  `409 no_trigger`.\n- The `contact_id` must belong to the calling client — otherwise\n  `404 not_found`. A contact absorbed by a merge also returns\n  `404 not_found`: trigger on the contact that was kept.\n\n### Enrollment guards\n\nManual triggering goes through the same guards as automatic\ntriggers:\n\n- Contact already in an active run of this automation — otherwise\n  `409 already_enrolled` (turn on Multiple concurrent runs in the\n  Enrollment settings to allow concurrent runs).\n- Duplicate within the last 5 minutes, cap of 5 enrollments per\n  contact per 24 h on this automation, or global cap of 200\n  executions per hour for the account — otherwise `429 trigger_throttled`.\n\nThe body accepts an optional `event_data` (free-form JSON object) injected into\nthe execution and available to the graph's template variables.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:trigger"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationTriggerInput"
              },
              "example": {
                "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                "event_data": {
                  "source": "crm_import"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "`pending` execution created — claimed asynchronously by the engine.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AutomationTriggerResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                    "automation_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "contact_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
                    "trigger_type": "manual_api",
                    "status": "pending",
                    "created_at": "2026-05-04T12:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/automations/{id}/history": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "get": {
        "tags": [
          "Automations"
        ],
        "operationId": "listAutomationHistory",
        "summary": "List an automation's execution history",
        "description": "Returns the paginated list of an automation's executions (cursor v2).\nStable sort `created_at DESC, id DESC` — most recent executions\nfirst. An execution is one run of the automation for a given\ncontact; the engine can restart it after a failure\n(`retry_count > 0`).\n\nThe automation must belong to the calling client and must not be\narchived — otherwise `404 not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of the automation's executions.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/AutomationHistoryEntry"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                      "automation_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "contact_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
                      "trigger_type": "manual_api",
                      "status": "completed",
                      "current_node_id": null,
                      "scheduled_at": null,
                      "execution_log": {
                        "steps": [
                          {
                            "action": "send_email",
                            "status": "delivered"
                          }
                        ]
                      },
                      "retry_count": 0,
                      "created_at": "2026-05-04T12:00:00.000Z",
                      "updated_at": "2026-05-04T12:00:05.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/pipeline/deals": {
      "get": {
        "tags": [
          "Pipeline"
        ],
        "operationId": "listDeals",
        "summary": "List pipeline deals",
        "description": "Returns the paginated list of the account's deals (opportunities) (cursor v2).\nStable sort `created_at DESC, id DESC`.\n\nA deal is NOT a separate entity in the database: it is a\n`client_contacts` row with `pipeline_stage_id IS NOT NULL`. The set\nof exposed fields is intentionally limited to what matters for\nthe pipeline view (contact details + stage + value + score). For the full\ncontact (custom_fields, tags, notes), use `GET /v1/contacts/{id}`.\n\n### Filters\n\n- `?stage_id` (uuid) — exact match on the pipeline stage.\n\n### Merged contacts\n\nA contact absorbed by a merge no longer appears in the list: its\nhistory lives in the surviving contact, which is the only one listed.\n\n### No single GET\n\nNo `GET /v1/pipeline/deals/{id}` endpoint is exposed — to\nretrieve an individual deal, use `GET /v1/contacts/{id}` or\nfilter this list by `?stage_id`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "stage_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Exact match on the pipeline stage."
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of deals.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Deal"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "email": "alex@example.com",
                      "phone": "+15145551234",
                      "first_name": "Alex",
                      "last_name": "Tremblay",
                      "company": "Acme Inc.",
                      "pipeline_stage_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                      "deal_value": 2500,
                      "created_at": "2026-05-04T10:00:00.000Z",
                      "updated_at": "2026-05-04T11:15:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Pipeline"
        ],
        "operationId": "createDeal",
        "summary": "Place a contact in the pipeline",
        "description": "Places an existing contact in a pipeline stage (sets\n`pipeline_stage_id`). **Does not create a contact** — `contact_id` must\nalready exist and belong to the calling account (otherwise `404 not_found`).\nThe same applies to `stage_id` (`404 not_found` if the stage does not belong\nto the account).\n\nA `contact_id` that points to a contact absorbed by a merge returns\n`404 not_found`: place the surviving contact instead. If the merge is\nundone from the dashboard, the contact becomes accessible again under\nits ID.\n\nIf the contact is already in a stage of the same pipeline, it is\nmoved there. The move goes through the same engine as the\ndashboard: it appears in the contact's timeline, triggers the\nstage automations and the webhook, and counts as a human\ndecision (the AI agent stops following up with this lead).\n\nA contact filed in a different pipeline than the target stage's\nis not moved: `422 business_rule_violation` with\n`details.reason` = `other_pipeline` (moving a contact between pipelines\nis done from the dashboard). An archived contact is not moved\neither (`details.reason` = `contact_archived`).\n\nA **disqualified** lead does not change stage until its\ndisqualification is lifted: the request returns\n`409 lead_disqualified`. Moving it to a lost stage, or to the\ndisqualification stage, is still accepted. You reopen the lead from\nits contact in the dashboard.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DealCreate"
              },
              "example": {
                "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                "stage_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Deal placed in the pipeline.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Deal"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "company": "Acme Inc.",
                    "pipeline_stage_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                    "deal_value": 2500,
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T12:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/LeadDisqualified"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/pipeline/deals/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "patch": {
        "tags": [
          "Pipeline"
        ],
        "operationId": "updateDeal",
        "summary": "Move a deal to another stage",
        "description": "Moves a deal (`pipeline_stage_id`) to a new stage. Only the\nstage move is exposed through this endpoint — to change the\ndeal's other fields (email, deal_value, etc.), use\n`PATCH /v1/contacts/{id}`.\n\n### Preconditions\n\n- The target deal must exist, belong to the calling account, and have\n  `pipeline_stage_id IS NOT NULL`, otherwise `404 not_found`. The ID of a\n  contact absorbed by a merge also returns `404 not_found`: move\n  the surviving contact.\n- The target stage must belong to the calling account, otherwise\n  `404 not_found`.\n- The lead must not be disqualified, otherwise\n  `409 lead_disqualified`. A lost stage or the disqualification\n  stage is still an accepted target; otherwise, reopen the lead\n  from its contact in the dashboard before retrying the request.\n- The target stage must belong to the deal's pipeline, and the contact\n  must not be archived, otherwise `422 business_rule_violation`\n  (`details.reason` = `other_pipeline` or `contact_archived`).\n\nThe move goes through the same engine as the dashboard: it\nappears in the contact's timeline, triggers the stage\nautomations and the webhook, and counts as a human decision (the AI\nagent stops following up with this lead).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DealUpdate"
              },
              "example": {
                "stage_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deal moved to the new stage.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Deal"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "company": "Acme Inc.",
                    "pipeline_stage_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
                    "deal_value": 2500,
                    "created_at": "2026-05-04T10:00:00.000Z",
                    "updated_at": "2026-05-04T13:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/LeadDisqualified"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Pipeline"
        ],
        "operationId": "removeDeal",
        "summary": "Remove a deal from the pipeline",
        "description": "Removes the deal from the pipeline by setting `pipeline_stage_id = NULL`. The\ncontact is NOT deleted — it remains accessible via\n`GET /v1/contacts/{id}`. To permanently delete the contact,\nuse `DELETE /v1/contacts/{id}`.\n\nThe target deal must exist, belong to the calling account, and have\n`pipeline_stage_id IS NOT NULL` — otherwise `404 not_found`. The ID of a\ncontact absorbed by a merge also returns `404 not_found`: remove the\nsurviving contact. If the merge is undone from the dashboard, the\ncontact becomes accessible again under its ID. An archived contact stays in\nits pipeline: `422 business_rule_violation` (`details.reason` =\n`contact_archived`).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Deal removed from the pipeline (the contact is not deleted).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/DealRemoveResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "removed_from_pipeline": true
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "422": {
            "$ref": "#/components/responses/BusinessRuleViolation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/pipeline/stages": {
      "get": {
        "tags": [
          "Pipeline"
        ],
        "operationId": "listStages",
        "summary": "List pipeline stages",
        "description": "Returns the full list of stages across the account's pipelines, sorted\nby pipeline (in dashboard order), then by `stage_order ASC`.\nEach stage names its pipeline (`pipeline.name`,\n`pipeline.is_default`): an account with several pipelines can have\ntwo stages with the same name. Each stage includes a\n`contact_count` computed in real time (the number of the account's contacts\ncurrently in that stage, excluding archived\nand merged contacts — useful for pipeline KPIs). A stage hidden in\nthe dashboard is still listed, flagged `is_hidden`.\n\n### Non-paginated response\n\nThe endpoint returns **all of the account's stages in a single call**\n(usually < 20 per account, max 50). The response format stays\n`list V2`-compatible for integration consistency\n(`pagination.next_cursor=null`, `pagination.has_more=false`,\n`pagination.limit` and `pagination.total_count` equal the number of\nstages returned).\n\n### Read-only\n\nCreating, editing, and reordering stages is done in the\ndashboard — no POST/PATCH/DELETE `/v1/pipeline/stages` endpoint\nis exposed.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Full list of pipeline stages (non-paginated).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Stage"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "name": "Nouveau lead",
                      "slug": "new-lead",
                      "color": "#94a3b8",
                      "stage_order": 1,
                      "is_default": true,
                      "is_won": false,
                      "is_lost": false,
                      "probability": 10,
                      "created_at": "2026-04-01T10:00:00.000Z",
                      "contact_count": 32,
                      "is_hidden": false,
                      "pipeline_id": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d",
                      "pipeline": {
                        "id": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d",
                        "name": "Pipeline",
                        "is_default": true
                      }
                    },
                    {
                      "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                      "name": "Qualifié",
                      "slug": "qualified",
                      "color": "#3b82f6",
                      "stage_order": 2,
                      "is_default": false,
                      "is_won": false,
                      "is_lost": false,
                      "probability": 50,
                      "created_at": "2026-04-01T10:00:00.000Z",
                      "contact_count": 17,
                      "is_hidden": false,
                      "pipeline_id": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d",
                      "pipeline": {
                        "id": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d",
                        "name": "Pipeline",
                        "is_default": true
                      }
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 2,
                    "cursor": null,
                    "total_count": 2
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/emails/conversations": {
      "get": {
        "tags": [
          "Emails"
        ],
        "operationId": "listEmailConversations",
        "summary": "List email conversations",
        "description": "Returns a paginated list of the account's email threads (cursor v2). Stable\nsort: `last_message_at DESC, id DESC` — conversations with a\nrecent exchange come first. `last_message_at` is `NOT NULL` in the table.\n\n### Read-only\n\nThe public API only exposes the list of threads. The individual\nmessages of a conversation aren't available through the\npublic API yet — the payload is limited to the thread's metadata.\nTo send an email, use `POST /v1/emails/send`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "email:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of email conversations.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EmailConversation"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "contact_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
                      "sender_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                      "subject": "Suivi de notre échange",
                      "status": "open",
                      "unread_count": 2,
                      "last_message_at": "2026-05-04T12:34:56.789Z",
                      "created_at": "2026-04-15T10:00:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 20,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/emails/send": {
      "post": {
        "tags": [
          "Emails"
        ],
        "operationId": "sendEmail",
        "summary": "Send a transactional email",
        "description": "Queues a transactional email for delivery through Resend.\nThe email is created with `status=\"queued\"`, `priority=1`,\n`type=\"transactional\"` and delivered asynchronously by the email\nworker — the 201 response confirms the email was queued, not that it\nwas delivered.\n\n### Sender resolved server-side\n\nThe sender (`from`) is derived from the account's default sender\n(`client_email_senders.is_default = true`). If no default\nsender is configured, the request returns\n`400 no_sender_configured`.\n\n### Idempotency\n\nThe `Idempotency-Key` header is NOT supported on this route — a\nnaive retry will create a second send. An internal idempotency key is\ngenerated server-side (`api-transactional/{clientId}/{ts}`) only to\nprotect against duplicate database inserts.\n\n### Test mode\n\n`\"test\": true` in the body lets you validate an integration without\ntargeting a real contact: personalization variables are\nfilled with sample values, the subject is prefixed with\n`[TEST]`, and recipient compliance checks are\nbypassed. In exchange, the recipient must be an active member\nof the account's team (otherwise 400 `recipient_not_allowed`), and\na quota of 50 tests per rolling 24 hours applies, shared with the\nplatform's test buttons (otherwise 429 `test_quota_exceeded`).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "email:send"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmailSendPayload"
              },
              "example": {
                "to": "alex@example.com",
                "subject": "Confirmation de votre commande",
                "body": "<p>Bonjour Alex,</p><p>Votre commande est confirmée.</p>"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Email queued.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/EmailSendResult"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "status": "queued",
                    "to_email": "alex@example.com",
                    "subject": "Confirmation de votre commande",
                    "created_at": "2026-05-04T12:00:00.000Z",
                    "is_test": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhooks",
        "summary": "List webhook subscriptions",
        "description": "Returns the list of webhook subscriptions created by the calling\nAPI key. Subscriptions created by another key on the same account\nare NOT visible here (filter `client_id AND api_key_id`).\n\n### No cursor pagination\n\nThe maximum of 10 subscriptions per account always fits on a\nsingle page. The response returns `pagination.next_cursor=null` and\n`has_more=false`.\n\n### Secret never returned\n\nThe HMAC secret used to sign outbound payloads is only\nreturned on creation (`POST /v1/webhooks`). It isn't\nreturned here — to rotate it, delete the subscription and\ncreate a new one.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "List of this API key's webhook subscriptions.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                      "url": "https://hooks.example.com/capturia",
                      "events": [
                        "lead_created",
                        "payment_received"
                      ],
                      "is_active": true,
                      "created_at": "2026-04-15T10:00:00.000Z"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 1,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhook",
        "summary": "Create a webhook subscription",
        "description": "Registers a new subscription. Capturia sends an HMAC-signed JSON `POST`\nto `url` every time a subscribed event occurs.\n\n### HTTPS URL required\n\nThe URL must use the `https://` scheme — `http://` is rejected to\navoid leaking payloads in cleartext.\n\n### Limit of 10 subscriptions per account\n\nBeyond 10 active subscriptions on the same account (across all\nkeys), returns `429 subscription_limit_reached`. Delete\nan existing subscription before you create a new one.\n\n### HMAC secret returned only ONCE\n\nThe 201 response includes `secret` in cleartext (64 hex characters, 256 bits\nof entropy). You MUST store it immediately — you use it to\nverify the signature of outbound payloads\n(`X-Capturia-Signature`), and the API never returns it again.\nThe secret is encrypted in the database after this response.\n\n### Scoped to the creating key\n\nThe subscription belongs to both the account AND the API key that\ncreated it. The `GET /v1/webhooks` and\n`DELETE /v1/webhooks/{id}` endpoints only see the current key's\nsubscriptions — a subscription created by another key on the same\naccount can't be listed or deleted through the API.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreate"
              },
              "example": {
                "url": "https://hooks.example.com/capturia",
                "events": [
                  "lead_created",
                  "payment_received"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Subscription created. The HMAC `secret` is included in the response\nin cleartext only ONCE — store it immediately.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/WebhookCreated"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "url": "https://hooks.example.com/capturia",
                    "events": [
                      "lead_created",
                      "payment_received"
                    ],
                    "is_active": true,
                    "created_at": "2026-05-04T12:00:00.000Z",
                    "secret": "8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook subscription",
        "description": "Permanently deletes a webhook subscription. The subscription\nmust belong to both the calling account AND the calling\nAPI key (filter `client_id AND api_key_id`) — otherwise\n`404 not_found`.\n\n### No soft delete\n\nDeletion is immediate and irreversible — to resume the\nevent stream, create a new subscription with\n`POST /v1/webhooks` (a new secret will be issued).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "responses": {
          "204": {
            "description": "Subscription deleted. No body.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/webhook-events": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookEvents",
        "summary": "List the latest deliverable business events (samples)",
        "description": "Returns the account's latest business events, in **exactly\nthe payload shape delivered by a webhook subscription** — the same\npayload builder powers real deliveries and this list.\n\n### What it's for\n\n- **`performList` in Zapier/Make apps**: when a user tests\n  a trigger, the app shows real recent samples, guaranteed\n  to match what a real delivery will send.\n- **Integration debugging**: inspect what would be delivered without\n  waiting for a new event.\n\nThe list combines two sources, from newest to oldest:\n\n- events **recorded in the account's delivery queue**,\n  with the exact data that was or will be delivered;\n- supplemented by events from the **activity log** that the queue\n  didn't record (no subscription was listening to them at the time),\n  formatted by the same builder.\n\nSo it works even when no webhook subscription exists.\nEvents emitted outside the log (`lead_updated`, `note_created`,\n`task_created`, `sms_received`, `email_sent`, `call_ended`…)\nonly appear once a subscription listens to them: as long\nas none does, they aren't recorded anywhere. Internal\nevent types (audit) never appear, and\nsensitive keys in the `changes` field are redacted just as they are on\ndelivery.\n\n### Difference from a real delivery\n\nEach item's `id` field is the event's identifier at its\nsource — on a real delivery, `id` is the delivery's unique\nidentifier (`X-Capturia-Delivery`). The `data.system` field is\nalways `null` in a sample: it is resolved at delivery\ntime. The shape and every other field are identical.\n\n### No cursor pagination\n\nThis is a feed of recent samples, capped at 25 items\n(`limit`, default 10). The response returns\n`pagination.next_cursor=null` and `has_more=false`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Restricts results to events of this type (a catalog value,\ne.g. `lead_created`, `booking_created`, `automation_completed`).\nA value outside the catalog returns `400 validation_error`\n(`reason: unknown_event_type`).\n",
            "schema": {
              "type": "string",
              "example": "lead_created"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum number of items (integer from 1 to 25, default 10). A non-integer or out-of-range value returns `400 validation_error`.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 25,
              "default": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Latest deliverable events, from newest to oldest.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "event",
                          "category",
                          "created_at",
                          "data"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The event's identifier at its source."
                          },
                          "event": {
                            "type": "string",
                            "description": "Event type (a catalog value)."
                          },
                          "category": {
                            "type": "string",
                            "description": "Event category (crm, automation, quote, ...)."
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "data": {
                            "type": "object",
                            "required": [
                              "resource_type",
                              "resource_id",
                              "resource_name",
                              "actor",
                              "changes",
                              "description",
                              "metadata"
                            ],
                            "properties": {
                              "resource_type": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "resource_id": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "resource_name": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "actor": {
                                "type": "object",
                                "properties": {
                                  "name": {
                                    "type": "string"
                                  },
                                  "type": {
                                    "type": [
                                      "string",
                                      "null"
                                    ],
                                    "enum": [
                                      "client_user",
                                      "team_member",
                                      null
                                    ],
                                    "description": "The member who acted; null for an action by the system, an automation, or an integration."
                                  },
                                  "id": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "via": {
                                    "type": [
                                      "string",
                                      "null"
                                    ],
                                    "enum": [
                                      "ai_assistant",
                                      "api_key",
                                      null
                                    ],
                                    "description": "The channel of an action taken outside the app — an AI assistant through the connector, or the public API; null otherwise."
                                  }
                                }
                              },
                              "changes": {
                                "type": [
                                  "object",
                                  "null"
                                ],
                                "description": "Diff of the changed fields, with sensitive keys redacted."
                              },
                              "description": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "metadata": {
                                "type": "object"
                              },
                              "contact": {
                                "type": [
                                  "object",
                                  "null"
                                ],
                                "description": "Identity of the lead involved, frozen at the time of\nthe event. A correlation key for finding\nthe contact in a connected CRM. `null` when\nthe event doesn't involve a lead (team,\ntags, stages…).\n",
                                "required": [
                                  "id"
                                ],
                                "properties": {
                                  "id": {
                                    "type": "string",
                                    "format": "uuid"
                                  },
                                  "first_name": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "last_name": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "email": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "phone": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "company": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  },
                                  "source": {
                                    "type": [
                                      "string",
                                      "null"
                                    ]
                                  }
                                }
                              },
                              "system": {
                                "type": [
                                  "object",
                                  "null"
                                ],
                                "description": "System in which the lead is engaged, resolved\nat delivery time. Always `null` in a\nsample.\n",
                                "properties": {
                                  "id": {
                                    "type": "string",
                                    "format": "uuid"
                                  },
                                  "name": {
                                    "type": "string"
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                },
                "example": {
                  "data": [
                    {
                      "id": "3d2a1b0c-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                      "event": "lead_created",
                      "category": "crm",
                      "created_at": "2026-07-01T12:00:00.000Z",
                      "data": {
                        "resource_type": "client_contacts",
                        "resource_id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
                        "resource_name": "Jean Bouchard",
                        "actor": {
                          "name": "Formulaire web",
                          "type": null,
                          "id": null,
                          "via": null
                        },
                        "changes": null,
                        "description": "Nouveau lead",
                        "metadata": {
                          "source": "funnel"
                        },
                        "contact": {
                          "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
                          "first_name": "Jean",
                          "last_name": "Bouchard",
                          "email": "jean@example.com",
                          "phone": "+15555550101",
                          "company": null,
                          "source": "funnel"
                        },
                        "system": null
                      }
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 1,
                    "cursor": null,
                    "total_count": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/presuasion-submissions": {
      "post": {
        "tags": [
          "Presuasion submissions"
        ],
        "operationId": "createPresuasionSubmission",
        "summary": "Submit a pre-suasion tool form",
        "description": "**Public endpoint — legacy pre-CAP-440 convention.** This endpoint\ndeparts from the standard v1 API conventions in several ways,\ndocumented below. The pre-suasion gateway (`demo.capturia.io`) calls it\nwhen a visitor completes a pre-suasion tool form published on a\nCapturia account's custom domain.\n\n### Authentication: defense in depth\n\nNo Bearer API key (the endpoint is called by a public gateway, not by a\nthird-party client). Three stacked layers protect the endpoint:\n\n1. **IP rate limit** (`RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT` requests per\n   `LEAD_WINDOW_SECONDS`) — cuts high-frequency noise.\n2. **HMAC signature** `X-Capturia-Signature: t=<unix>,v1=<hex>` —\n   Stripe webhook pattern, SHA-256 over `${timestamp}.${rawBody}`,\n   5-minute replay window, two-key rotation (`PRESUASION_HMAC_SECRET` +\n   `PRESUASION_HMAC_SECRET_PREVIOUS`). Log-only mode by default, switched\n   to fail-closed with `PRESUASION_HMAC_REQUIRED=true`.\n3. **Cloudflare Turnstile token** (`turnstile_token` body field)\n   verified through siteverify with `idempotency_key` = the body's\n   `submission_id` (stable across retries of the same submission — a\n   token is single-use, and Cloudflare replays the original verdict for\n   the same key), falling back to the request's `request_id` when the\n   submission carries no `submission_id`. Log-only mode by default,\n   switched to fail-closed with\n   `PRESUASION_TURNSTILE_REQUIRED=true`.\n\nIn addition, a **quota per `proposition_slug`** (100 submissions per\nUTC day, Upstash KV) limits the blast radius of a targeted attack even\nif the other layers are bypassed.\n\nThe target account is resolved server-side through `proposition_slug` →\n`presuasion_instances.client_id` — `client_id` is never accepted from\nthe body.\n\n### Custom body and response format\n\nThe body follows a schema specific to this endpoint\n(`PresuasionSubmissionPayload`). The 200 response returns a flat object\n`{success, contact_id, contact_created, activity_id}` instead of the\nstandard v1 `{data: ...}` envelope. Errors return\n`{error: \"string\", issues?: [...]}` instead of `ErrorEnvelope`.\n\n### Behavior\n\n- Resolves the target account through an active `proposition_slug`.\n- Finds the contact with the shared funnel engine: matched by email\n  (case-insensitive, secondary emails included), then by a phone number\n  already known for this account — the existing contact is then\n  enriched with the submitted email as a secondary email, never\n  duplicated and never failing on a uniqueness error. Names are filled\n  in without overwriting, the phone number is set if it was missing (a\n  number already held by another contact stays on the submission), and\n  the implied SMS consent triplet is refreshed on every submission\n  (subject to the STOP guard). On the tokenized path\n  (`identity_confirmed`), the link's contact receives the same additive\n  enrichment.\n- Capture routing set on the tool: a contact **created** by the\n  submission starts in the tool's entry column; the tool's automatic\n  tags are added to every captured contact (created or matched, through\n  the email or the link path); a matched contact that is in no column\n  enters one through the tool. A contact already placed is never moved.\n- Inserts a `presuasion_submission` activity into the contact's\n  timeline with a formatted human-readable `Q/R` block + raw `metadata`\n  (responses, result_data, tool_name, proposition). The tool is\n  identified there by its instance: `metadata.tool_id` and\n  `metadata.proposition_id` carry the identifier of the resolved\n  proposal, like captures from embedded tools.\n\n### Rate limit\n\nEmits the legacy `X-RateLimit-*` headers (IP counter) — not the\nRFC 9331 `RateLimit-*` headers used by the rest of the v1 API.\n",
        "security": [],
        "parameters": [
          {
            "in": "header",
            "name": "X-Capturia-Signature",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^t=\\d+,v1=[0-9a-f]{64}$",
              "example": "t=1714834200,v1=0a2c…d3"
            },
            "description": "HMAC SHA-256 signature computed by the gateway over\n`${timestamp}.${rawBody}` with `PRESUASION_HMAC_SECRET`. 5-minute\nreplay window. Optional in log-only mode; required\nwhen `PRESUASION_HMAC_REQUIRED=true` (401 response otherwise).\n"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PresuasionSubmissionPayload"
              },
              "example": {
                "proposition_slug": "blueprint-ventes-2026",
                "tool_id": "calculateur-roi",
                "tool_name": "Calculateur de ROI",
                "email": "alex@example.com",
                "first_name": "Alex",
                "phone": "+15145551234",
                "responses": {
                  "budget": "500-1000",
                  "timeline": "3-mois"
                },
                "formatted_answers": [
                  {
                    "question": "Quel est ton défi principal en ventes ?",
                    "answer": "Je perds des leads parce que je ne suis pas assez rapide à les rappeler."
                  }
                ],
                "result_data": {
                  "score": 87,
                  "recommendation": "premium"
                },
                "completed_at": "2026-05-04T12:34:56.789Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Submission recorded. Contact upserted + `presuasion_submission`\nactivity created in the timeline.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionResponse"
                },
                "example": {
                  "success": true,
                  "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                  "contact_created": false,
                  "activity_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                }
              }
            }
          },
          "400": {
            "description": "Invalid JSON body or invalid Zod payload. **Legacy\nconvention** — custom `{error, issues?}` body that does not follow the\nv1 catalog's `ErrorEnvelope`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "examples": {
                  "invalidJson": {
                    "summary": "Malformed JSON",
                    "value": {
                      "error": "Invalid JSON body"
                    }
                  },
                  "invalidPayload": {
                    "summary": "Zod validation failed",
                    "value": {
                      "error": "Invalid payload",
                      "issues": [
                        {
                          "path": "formatted_answers.0.question",
                          "message": "String must contain at least 1 character(s)"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`X-Capturia-Signature` HMAC signature missing, malformed,\nexpired (5-minute window), or invalid. Returned only when\n`PRESUASION_HMAC_REQUIRED=true` (fail-closed mode). In log-only\nmode, unsigned requests go through and a warning is\nemitted for observability.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Invalid signature"
                }
              }
            }
          },
          "403": {
            "description": "Cloudflare Turnstile token missing, expired, or rejected by\nsiteverify. Returned only when\n`PRESUASION_TURNSTILE_REQUIRED=true` (fail-closed mode).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Captcha verification failed"
                }
              }
            }
          },
          "404": {
            "description": "Unknown or disabled `proposition_slug`\n(`presuasion_instances.is_active = false`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Proposition not found or inactive"
                }
              }
            }
          },
          "409": {
            "description": "Two variants, refused before any database write (no\ncontact, activity, or enrichment):\n\n* **Unlinked demo** — the `proposition_slug` exists but the\n  proposal is not linked to any Capturia account\n  (`client_id IS NULL`). A demo without an account never creates a\n  contact: the submission is explicitly refused rather than\n  faked as a success, and the refusal is logged on the Capturia side.\n* **Invalid link identity** — `identity_confirmed: true` while\n  the `token` no longer resolves to any contact of this account. No\n  contact is created from an identity that cannot be found. This\n  variant is evaluated after the per-`proposition_slug` quota:\n  when the signature and Turnstile are valid, it consumes one\n  unit of the daily quota. The unlinked demo consumes\n  none.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "examples": {
                  "unattachedDemo": {
                    "summary": "Demo not linked to an account",
                    "value": {
                      "error": "Proposition not linked to a Capturia client"
                    }
                  },
                  "identityTokenInvalid": {
                    "summary": "Tokenized link that no longer resolves",
                    "value": {
                      "error": "identity_token_invalid"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Two possible variants:\n\n* **IP rate limit exceeded** — `{error: \"Too many requests\"}` body,\n  `X-RateLimit-*` headers (legacy convention).\n* **Per-`proposition_slug` quota exceeded** (100/UTC day) — body\n  `{error: \"Slug quota exceeded\"}` with the standard\n  `Retry-After` header (seconds until UTC midnight) in addition to the\n  `X-RateLimit-*` headers.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "examples": {
                  "ipRateLimit": {
                    "summary": "IP rate limit",
                    "value": {
                      "error": "Too many requests"
                    }
                  },
                  "slugQuota": {
                    "summary": "Daily slug quota exceeded",
                    "value": {
                      "error": "Slug quota exceeded"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Server error (proposal lookup, contact upsert, or activity\ninsert). **Legacy convention** — custom body\n`{error: \"string\"}` instead of `ErrorEnvelope`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "examples": {
                  "serverError": {
                    "summary": "Generic error (proposal lookup)",
                    "value": {
                      "error": "Server error"
                    }
                  },
                  "upsertFailed": {
                    "summary": "Contact upsert failed",
                    "value": {
                      "error": "Failed to upsert contact"
                    }
                  },
                  "activityFailed": {
                    "summary": "Activity insert failed",
                    "value": {
                      "error": "Failed to record activity"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "cap_live_<hex64>",
        "description": "Authenticate with an API key in the `cap_live_<hex64>` format, passed in the\n`Authorization: Bearer cap_live_...` header. Legacy keys in the `cap_<hex64>` format\nare also accepted until 2027-05-03.\n\n## Available scopes\n\nEach key carries a list of scopes that determine which operations it\ncan perform. Scopes are checked server-side on every request.\n20 scopes in total: 8 read scopes, 10 write/trigger scopes,\nand 2 ingestion scopes.\n\n### Read scopes\n\n- `contacts:read` — Read contacts\n- `pipeline:read` — Read pipeline deals and stages\n- `conversations:read` — Read conversation threads (chat, SMS, call, email)\n- `bookings:read` — Read bookings\n- `quotes:read` — Read quotes\n- `tags:read` — Read tags\n- `automations:read` — Read automations\n- `email:read` — Read email threads\n\n### Write scopes\n\n- `contacts:write` — Create/update contacts\n- `contacts:delete` — Permanently delete contacts (only someone who can delete leads in Capturia can grant it; included only in `Full integration`)\n- `pipeline:write` — Create/update deals\n- `bookings:write` — Create/update bookings\n- `quotes:write` — Create/update quotes\n- `tags:write` — Create/update tags\n- `automations:trigger` — Manually trigger an automation\n- `email:send` — Send a transactional email\n- `webhooks:manage` — Create/update outbound webhooks\n- `campaigns:launch` — Preview, then launch an SMS follow-up campaign (MCP connector, two-step confirmation required)\n\n### Ingestion scopes\n\n- `leads:capture` — Push leads from an external funnel (the most common scope for partner integrations)\n- `events:ingest` — Push custom events that trigger automations (`POST /v1/events`)\n\n## Presets\n\nTo make key creation easier, 5 scope presets are offered when you create a key:\n`Lead capture` (`leads:capture` only), `Read only` (all `:read`),\n`Read + write` (all `:read` + `:write` + `leads:capture` + `events:ingest`),\n`Full integration` (all scopes), `Custom` (free selection).\n\nFull documentation: https://capturia.io/en/developers/api/v1/authentication\n"
      }
    },
    "headers": {
      "XRequestID": {
        "description": "ULID of the request, which support can use for debugging.",
        "schema": {
          "type": "string",
          "pattern": "^req_[0-9A-HJKMNP-TV-Z]{26}$"
        }
      },
      "CapturiaRequestID": {
        "description": "Alias of X-Request-ID (for compatibility with clients that filter legacy `X-*` headers).",
        "schema": {
          "type": "string",
          "pattern": "^req_[0-9A-HJKMNP-TV-Z]{26}$"
        }
      },
      "CapturiaVersion": {
        "description": "Version of the API that served the response (always `v1` today).",
        "schema": {
          "type": "string",
          "enum": [
            "v1"
          ]
        }
      },
      "RateLimitLimit": {
        "description": "Request limit per window (RFC 9331).",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Requests remaining in the current window (RFC 9331).",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitReset": {
        "description": "Seconds until the window resets (RFC 9331, delta-seconds).",
        "schema": {
          "type": "integer"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (RFC 7231). Sent with 429 and 503.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitPolicy": {
        "description": "Rate limit policy in structured format (RFC 9331), e.g. `100;w=3600`.",
        "schema": {
          "type": "string",
          "example": "100;w=3600"
        }
      },
      "XRateLimitLimit": {
        "description": "Request limit (legacy, sunset 2027-05-03).",
        "deprecated": true,
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitRemaining": {
        "description": "Remaining requests (legacy, sunset 2027-05-03).",
        "deprecated": true,
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitReset": {
        "description": "ISO timestamp of the reset (legacy, sunset 2027-05-03).",
        "deprecated": true,
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      }
    },
    "schemas": {
      "ApiScope": {
        "type": "string",
        "description": "Authorization scope carried by an API key. Scopes control which\nroutes are accessible: `:read` for reads, `:write` for mutations,\nplus a few specific scopes (`contacts:delete`, `leads:capture`,\n`events:ingest`, `automations:trigger`, `email:send`, `webhooks:manage`,\n`campaigns:launch`). You change an active key's scopes in the\nkey's Permissions tab, in Capturia.\n",
        "enum": [
          "contacts:read",
          "contacts:write",
          "contacts:delete",
          "pipeline:read",
          "pipeline:write",
          "conversations:read",
          "bookings:read",
          "bookings:write",
          "quotes:read",
          "quotes:write",
          "tags:read",
          "tags:write",
          "automations:read",
          "automations:trigger",
          "email:send",
          "email:read",
          "webhooks:manage",
          "leads:capture",
          "events:ingest",
          "campaigns:launch"
        ]
      },
      "ApiKeyPreset": {
        "type": "string",
        "description": "Preset inferred from the key's set of effective scopes (not stored in\nthe database — recomputed at runtime).\n\n- `lead_capture` — scopes = exactly `[leads:capture]`\n- `read_only` — all `:read` scopes\n- `read_write` — all `:read` + all `:write` + `leads:capture` + `events:ingest`\n- `full_integration` — all available scopes\n- `custom` — any combination that doesn't match a known preset\n",
        "enum": [
          "lead_capture",
          "read_only",
          "read_write",
          "full_integration",
          "custom"
        ],
        "example": "lead_capture"
      },
      "Timestamp": {
        "type": "string",
        "format": "date-time",
        "description": "ISO 8601 UTC timestamp.",
        "example": "2026-05-04T12:34:56.789Z"
      },
      "ApiKeyMe": {
        "type": "object",
        "description": "Detailed view of the authenticated API key, returned by `GET /v1/me`.\nUse it as a smoke test — a developer integrating Capturia can quickly\nconfirm that their key works and read the details it exposes\n(target client, scopes, preset, expiration, applicable rate limit).\n\nThe key's secret is never returned — only `key_prefix` (the first 8-12\ncharacters, such as `cap_live_…`) serves as a human-readable identifier\nin the dashboard.\n",
        "required": [
          "key_id",
          "key_name",
          "key_prefix",
          "key_format_version",
          "client_id",
          "scopes",
          "preset",
          "lifecycle_state",
          "created_at",
          "rate_limits"
        ],
        "properties": {
          "key_id": {
            "type": "string",
            "format": "uuid",
            "description": "Internal unique ID of the key.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "key_name": {
            "type": "string",
            "description": "Human-readable name given to the key when it was created (dashboard label).",
            "example": "Make.com production"
          },
          "key_prefix": {
            "type": "string",
            "description": "Non-secret prefix of the key (e.g. `cap_live_a1b2c3`) — shown in the\ndashboard so you can visually identify the key without exposing the secret.\n",
            "example": "cap_live_a1b2c3"
          },
          "key_format_version": {
            "type": "integer",
            "description": "Version of the key format (1, 2, …). Lets the format evolve\nwithout breaking existing keys. New keys\nalways use the latest version.\n",
            "example": 2
          },
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "Capturia client the key grants access to (tenant isolation).",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "client_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable name of the target client. `null` if the client has no name\nset (rare; system accounts).\n",
            "example": "Acme Inc."
          },
          "scopes": {
            "type": "array",
            "description": "Effective scopes carried by the key (filtered against the list of\nvalid scopes — any unknown scopes in the database are dropped).\n",
            "items": {
              "$ref": "#/components/schemas/ApiScope"
            },
            "example": [
              "leads:capture"
            ]
          },
          "preset": {
            "$ref": "#/components/schemas/ApiKeyPreset"
          },
          "lifecycle_state": {
            "type": "string",
            "description": "Lifecycle state of the key. Typical values: `active`,\n`paused`, `revoked`. A `paused` or `revoked` key fails\nauthentication — if you see `active` here, the key\nworks.\n",
            "example": "active"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Timestamp of the last API call authenticated with this key. `null`\nif never used.\n",
            "example": "2026-05-04T12:34:56.789Z"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Scheduled expiration timestamp. `null` if the key doesn't expire\nautomatically. An expired key returns 401 `expired_api_key`.\n",
            "example": "2027-05-04T00:00:00.000Z"
          },
          "rate_limits": {
            "type": "object",
            "description": "Per-key rate limit that applies to all v1 routes.",
            "required": [
              "per_hour"
            ],
            "properties": {
              "per_hour": {
                "type": "integer",
                "minimum": 0,
                "description": "Maximum number of requests per rolling hour for this key — same window as the RateLimit-Policy header (w=3600).",
                "example": 200
              }
            }
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "type",
          "code",
          "message",
          "doc_url",
          "request_id"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "authentication_error",
              "authorization_error",
              "validation_error",
              "rate_limit_error",
              "idempotency_error",
              "not_found_error",
              "conflict_error",
              "business_rule_error",
              "server_error"
            ],
            "description": "Machine-readable category of the error."
          },
          "code": {
            "type": "string",
            "description": "Stable identifier of the error (see doc_url for the full list).",
            "example": "insufficient_scope"
          },
          "message": {
            "type": "string",
            "description": "Human-readable message localized according to Accept-Language (fr or en)."
          },
          "hint": {
            "type": "string",
            "description": "Actionable suggestion to resolve the error (e.g. \"Add scope X\")."
          },
          "doc_url": {
            "type": "string",
            "format": "uri",
            "description": "Link to the documentation for this specific error.",
            "example": "https://capturia.io/fr/developers/api/v1/authentication#scopes"
          },
          "dashboard_url": {
            "type": "string",
            "format": "uri",
            "description": "Actionable dashboard link (e.g. the scope editing page of the key that triggered the error)."
          },
          "request_id": {
            "type": "string",
            "pattern": "^req_[0-9A-HJKMNP-TV-Z]{26}$",
            "description": "ULID of the request. Provide it to support for debugging.",
            "example": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7"
          },
          "details": {
            "type": "object",
            "description": "Additional fields specific to the error type (validation_errors, retry_after_seconds, limit_type, etc.).",
            "additionalProperties": true
          }
        }
      },
      "UsageBucket": {
        "type": "object",
        "description": "Usage aggregated over a time window (`api_usage_aggregations`).\nA bucket covers one hour or one day, depending on the `granularity` requested\non `GET /v1/usage`. Latencies are in milliseconds\n(server-side percentiles, `null` if no request was measured in the window).\n\nEach request falls into exactly one family: `status_2xx`, `status_4xx`,\n`status_5xx`, `rate_limited_count`, or `status_unknown`. The rare 1xx and\n3xx statuses belong to no family but are still counted in\n`request_count`.\n",
        "required": [
          "bucket_start",
          "request_count",
          "status_2xx",
          "status_4xx",
          "status_5xx",
          "rate_limited_count",
          "status_unknown"
        ],
        "properties": {
          "bucket_start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the time window (ISO 8601 UTC timestamp). For the `hour`\ngranularity: aligned to the top of the hour. For `day`: aligned\nto 00:00:00 UTC.\n",
            "example": "2026-05-04T12:00:00.000Z"
          },
          "request_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Total number of requests attributed to the key in the window, whatever\ntheir outcome (rate limit rejections and unknown outcomes included).\n",
            "example": 1239
          },
          "status_2xx": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of 2xx responses (success).",
            "example": 1200
          },
          "status_4xx": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of 4xx responses (client errors: validation, scope, plan,\netc.). Rejections from a rate limiter are not included here: they are counted\nin `rate_limited_count`. A business-rule 429 (trigger called\ntoo often, test send quota, webhook subscription cap)\nis a client error counted here, and it consumes the hourly quota.\n",
            "example": 30
          },
          "status_5xx": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of 5xx responses (server errors).",
            "example": 4
          },
          "rate_limited_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of requests rejected with a 429 by a rate limiter (the key's hourly\nquota, brute-force protection, an endpoint's per-IP-address\nlimit). A family separate from `status_4xx`, never included\nin it. These requests don't consume the hourly quota — useful for\nsizing your usage.\n",
            "example": 5
          },
          "status_unknown": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of requests whose outcome wasn't recorded (response still\nin progress at aggregation time, or recording the outcome\nfailed). Never counted as successes or as errors; excluded\nfrom error rate calculations.\n",
            "example": 0
          },
          "latency_p50_ms": {
            "type": [
              "number",
              "null"
            ],
            "description": "Median (p50) latency in milliseconds over the window. `null` if not measurable.",
            "example": 42.5
          },
          "latency_p95_ms": {
            "type": [
              "number",
              "null"
            ],
            "description": "p95 latency in milliseconds.",
            "example": 180
          },
          "latency_p99_ms": {
            "type": [
              "number",
              "null"
            ],
            "description": "p99 latency in milliseconds.",
            "example": 350
          }
        }
      },
      "UsageTotals": {
        "type": "object",
        "description": "Totals computed application-side by summing the returned `buckets`\n(no aggregated latency — percentiles aren't additive).\n",
        "required": [
          "request_count",
          "status_2xx",
          "status_4xx",
          "status_5xx",
          "rate_limited_count",
          "status_unknown"
        ],
        "properties": {
          "request_count": {
            "type": "integer",
            "minimum": 0
          },
          "status_2xx": {
            "type": "integer",
            "minimum": 0
          },
          "status_4xx": {
            "type": "integer",
            "minimum": 0
          },
          "status_5xx": {
            "type": "integer",
            "minimum": 0
          },
          "rate_limited_count": {
            "type": "integer",
            "minimum": 0
          },
          "status_unknown": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "UsageResponse": {
        "type": "object",
        "description": "Response from `GET /v1/usage`. Implicitly filtered to the authenticated key\n(`api_key_id` = current key). The time range (`from` → `to`) must\nbe strictly positive and must not exceed 90 days\n(otherwise 400 `invalid_request`).\n\n`buckets` are sorted by `bucket_start ASC`. A window with no traffic\nis omitted from the array (buckets aren't zero-padded).\n",
        "required": [
          "api_key_id",
          "from",
          "to",
          "granularity",
          "buckets",
          "totals"
        ],
        "properties": {
          "api_key_id": {
            "type": "string",
            "format": "uuid",
            "description": "ID of the measured API key (mirrors the authenticated key).",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "from": {
            "type": "string",
            "format": "date-time",
            "description": "Lower bound of the requested range (ISO 8601 UTC, normalized).",
            "example": "2026-05-01T00:00:00.000Z"
          },
          "to": {
            "type": "string",
            "format": "date-time",
            "description": "Upper bound of the requested range (exclusive, ISO 8601 UTC, normalized).",
            "example": "2026-05-04T00:00:00.000Z"
          },
          "granularity": {
            "type": "string",
            "description": "Granularity of the returned buckets.",
            "enum": [
              "hour",
              "day"
            ],
            "example": "day"
          },
          "buckets": {
            "type": "array",
            "description": "Aggregation buckets sorted by `bucket_start ASC`. Empty if there was no traffic in the range.",
            "items": {
              "$ref": "#/components/schemas/UsageBucket"
            }
          },
          "totals": {
            "$ref": "#/components/schemas/UsageTotals"
          }
        }
      },
      "LeadCaptureUtm": {
        "type": "object",
        "description": "Optional UTM parameters attached to the lead. Used to attribute the\nmarketing source in the pipeline (`Source : Google Ads / Facebook campaign X`).\nYou can also send each sub-field flat (`utm.source`); see\n`LeadCapturePayload`. An unknown sub-field is not rejected: it is\nignored and named in the response's `ignored_fields`.\n",
        "additionalProperties": true,
        "properties": {
          "source": {
            "type": "string",
            "maxLength": 100,
            "example": "google"
          },
          "medium": {
            "type": "string",
            "maxLength": 100,
            "example": "cpc"
          },
          "campaign": {
            "type": "string",
            "maxLength": 200,
            "example": "spring-2026"
          },
          "content": {
            "type": "string",
            "maxLength": 200,
            "example": "ad-variant-a"
          },
          "term": {
            "type": "string",
            "maxLength": 200,
            "example": "automatisation+ventes"
          }
        }
      },
      "LeadCaptureQualificationAnswer": {
        "type": "object",
        "description": "Answer to a qualification question asked upstream (funnel form,\npre-suasion quiz). Stored to enrich the SMS bot's context and feed\nlead scoring. An unknown key is not rejected: it is ignored and named\nin the response's `ignored_fields`\n(`qualification_answers[0].ma_cle`).\n",
        "required": [
          "question",
          "answer"
        ],
        "additionalProperties": true,
        "properties": {
          "question": {
            "type": "string",
            "maxLength": 500,
            "example": "Quel est ton budget mensuel pour résoudre ce problème ?"
          },
          "answer": {
            "type": "string",
            "maxLength": 1000,
            "example": "Entre 500 $ et 1 000 $ / mois"
          }
        }
      },
      "LeadCapturePipelineConfig": {
        "type": "object",
        "description": "Optional routing of the lead in the sales pipeline. All fields are\noptional. Without `pipeline_slug`, the account's default pipeline is\ntargeted. If `stage_slug` is omitted, the pipeline's default stage is used.\n`assigned_sales_rep_email` must match a member of the client's team\n(otherwise the returned `assignment_status` is `email_not_found`).\n\nYou identify the pipeline and the stage by their **text identifier** (slug),\nnever by their technical identifier (UUID): `id`, `stage_id`, or any other\nunknown sub-field is ignored and named in the response's `ignored_fields`\n(`unknown_field`). A slug that matches nothing is rejected with a\n422 (`pipeline_not_found`, `stage_not_found`).\n\nYou can also send each sub-field flat (`pipeline.stage_slug`); see\n`LeadCapturePayload`.\n\nOn an existing contact, these fields are only written where the contact is\nempty: the ones it keeps are named in `ignored_fields`\n(`existing_contact_kept`).\n",
        "additionalProperties": true,
        "properties": {
          "pipeline_slug": {
            "type": "string",
            "maxLength": 100,
            "description": "Text identifier of the target pipeline. Copy it from the dashboard:\nAdministration, API Keys, section \"Your pipeline and stage\nidentifiers\".\n",
            "example": "ventes-b2b"
          },
          "stage_slug": {
            "type": "string",
            "maxLength": 100,
            "description": "Text identifier of the target stage in the pipeline, copied from the\nsame place as `pipeline_slug`. Unique within its pipeline only: two\npipelines can share the same stage identifier. Without\n`pipeline_slug`, it is looked up in the account's default pipeline.\nIf omitted, the pipeline's default stage is used; a pipeline with no\ndefault stage receives the lead in its first stage that is not won,\nlost, or hidden and has no system role: the stage marked \"Default\nstage\" next to the identifiers.\n",
            "example": "nouveau-lead"
          },
          "deal_value": {
            "type": "number",
            "minimum": 0,
            "maximum": 100000000,
            "description": "Estimated deal value in CAD (0 to 100 M). A strictly numeric\nstring (`\"2500\"`, `\"1500,50\"`) is accepted and converted: key/value\nconnectors only send text.\n",
            "example": 2500
          },
          "expected_close_date": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Expected closing date in ISO `YYYY-MM-DD` format. Strict format:\na full timestamp is rejected.\n",
            "example": "2026-06-30"
          },
          "assigned_sales_rep_email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "description": "Email of the sales rep to assign. Must match an active member of the\ncalling client (`client_users.status = 'active'`) who has already\nactivated their account (invitation accepted). Otherwise the capture\ncontinues without assignment and the response returns\n`assignment_status = email_not_found` (not an error).\n",
            "example": "alex@acme.com"
          }
        }
      },
      "LeadCaptureMetaAttribution": {
        "type": "object",
        "description": "The visitor's Meta ad attribution, captured on the funnel at submission\nand persisted (first-touch) on the contact. Used to attribute the\nserver-side Purchase event (Conversions API) to the original click when\nthe deal moves to \"won\". All fields are optional; providing whatever is\navailable improves Meta's match quality. A returning lead does not\noverwrite its original attribution (first-touch).\n\nThe `ad_id`/`adset_id`/`campaign_id` identifiers (and their names) also\nlink the lead to the ad that brought it in, visible on its contact and in\nper-ad performance. Connectors hooked up to Meta Lead Ads (Zapier, Make,\nn8n) have these fields in the lead\npayload.\n\nYou can also send each sub-field flat (`meta.ad_id`); see\n`LeadCapturePayload`. An unknown sub-field is not rejected: it is\nignored and named in the response's `ignored_fields`.\n",
        "additionalProperties": true,
        "properties": {
          "fbc": {
            "type": "string",
            "maxLength": 255,
            "description": "Facebook click cookie `_fbc` (passed to Meta as is).",
            "example": "fb.1.1700000000000.AbCdEf123"
          },
          "fbp": {
            "type": "string",
            "maxLength": 255,
            "description": "Facebook browser cookie `_fbp` (passed to Meta as is).",
            "example": "fb.1.1700000000000.987654321"
          },
          "fbclid": {
            "type": "string",
            "maxLength": 512,
            "description": "Facebook click parameter `fbclid` (from the landing URL)."
          },
          "event_source_url": {
            "type": "string",
            "maxLength": 2048,
            "description": "URL where the conversion was initiated (the funnel page).",
            "example": "https://immoacademie.com/offre"
          },
          "client_ip_address": {
            "type": "string",
            "maxLength": 45,
            "description": "IP address of the original visitor, passed along by the funnel (not the\nIP of the calling server). Accepted to improve Meta's match quality.\n"
          },
          "client_user_agent": {
            "type": "string",
            "maxLength": 512,
            "description": "User-Agent of the original visitor's browser, passed along by the funnel."
          },
          "ad_id": {
            "type": "string",
            "maxLength": 64,
            "description": "Meta identifier of the ad that generated the lead.",
            "example": "120210000000000001"
          },
          "adset_id": {
            "type": "string",
            "maxLength": 64,
            "description": "Meta identifier of the ad set."
          },
          "campaign_id": {
            "type": "string",
            "maxLength": 64,
            "description": "Meta identifier of the campaign.",
            "example": "120210000000000002"
          },
          "ad_name": {
            "type": "string",
            "maxLength": 255,
            "description": "Name of the ad at Meta (the label shown on the contact).",
            "example": "Pub verte — piscine creusée"
          },
          "campaign_name": {
            "type": "string",
            "maxLength": 255,
            "description": "Name of the campaign at Meta.",
            "example": "Usine à leads — octobre"
          },
          "ads_consent": {
            "description": "The lead's advertising consent (Quebec Law 25). Omitted = you attest that\nconsent was collected upstream (your own banner, or a Meta Lead Ads\nlead), and conversion events are sent to Meta.\n`false` = the lead declined ad tracking: no Meta event is sent, no\ntracking identifier (`fbc`/`fbp`) is kept, and the refusal is\npersisted on the contact; it also blocks the contact's future events\n(won sale, qualified lead). Boolean, or the no-code connector\nequivalent: numeric `1`/`0`, or a string\n(`\"true\"`/`\"yes\"`/`\"oui\"`/`\"1\"` and `\"false\"`/`\"no\"`/`\"non\"`/`\"0\"`,\ncase-insensitive). `null` counts as \"field omitted\".\n",
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "string"
              },
              {
                "type": "number"
              },
              {
                "type": "null"
              }
            ],
            "example": false
          }
        }
      },
      "LeadCapturePayload": {
        "type": "object",
        "description": "Lead capture body (`POST /v1/leads/capture`). `phone` and\n`sms_consent` are required: Capturia is SMS-first, and capture\nrequires an explicit opt-in.\n\n`phone` is normalized server-side before validation: a 10-digit number\nwith no country code is assumed to be NANP (`+1XXXXXXXXXX`), an\n11-digit number starting with `1` gets the `+`, and formats with\nparentheses/dashes (`(514) 123-4567`, `1-514-123-4567`) are accepted.\nFor an international number, explicitly include the `+` and the\ncountry code (e.g. `+33612345678`). A phone delivered as a JSON number\n(a no-code connector's numeric field) is accepted if it is a positive\ninteger; see the `phone` property.\n\n`sms_consent` accepts the boolean `true` or the truthy strings\n`\"true\" | \"yes\" | \"oui\" | \"vrai\" | \"1\" | \"on\" | \"checked\"`\n(case-insensitive). Any other value is rejected. Zapier, Make, and\nmost form builders stringify booleans, so accepting these common\nvariants avoids losing valid\nconsents.\n\nBody cap: 1 MB. Above that: 400 `payload_too_large`.\n\nNo-code tolerance: identity fields are normalized server-side\n(case and accents ignored, common aliases such as `firstName`, `Full Name`,\n`phone_number`, `Courriel` mapped to the canonical keys). Unrecognized\nkeys are ignored rather than rejected, hence `additionalProperties`,\nand **named in the response's `ignored_fields`**: a capture that\nreturns 200 tells you what it did not take (see `ignored_fields`, and its\none limitation). An optional field sent empty (`\"\"` or `null`) is treated\nas absent rather than rejected. `tags` also accepts a comma-separated\nstring (`\"chaud, webinaire\"`), coerced into an array.\n\nFlat blocks: connectors that only send key/value pairs\n(the Webhook action of a GoHighLevel workflow, Zapier's Data fields) can\nfill `pipeline`, `utm`, and `meta` with dotted keys, at the root or\nin `customData`: `pipeline.stage_slug`, `utm.source`, `meta.ad_id`.\nAn explicit nested block takes precedence, sub-field by sub-field.\n\nGoHighLevel webhooks: the Webhook action of GHL workflows wraps the\n\"Custom Data\" pairs in a nested `customData` object. Capturia\nunwraps this wrapper one level: each pair is processed as if it\nwere at the root (same aliases, same validation). No configuration\nis required on the GHL side.\n\nDuplicates: when the same field arrives from several sources, the one kept\nis, in order, the canonical key at the root, the one in `customData`, an\nalias at the root, then an alias in `customData`; an explicit block wins over\nits flat keys. Two exceptions: tags from the root and from\n`customData` are combined, and two blocks (`pipeline`, `utm`, `meta`,\n`custom_fields`) complement each other sub-field by sub-field. A discarded\ncopy that carried a different value is named in `ignored_fields`\n(`duplicate_field`). The comparison follows what Capturia stores: a\nphone is compared by its digits, a consent by its meaning, a\ndeal value by its amount (`\"1500,50\"` equals `1500.5`), emails\nand a System identifier case-insensitively; everywhere else case\nmatters (slug, click identifier, custom value).\n",
        "required": [
          "phone",
          "sms_consent"
        ],
        "additionalProperties": true,
        "properties": {
          "phone": {
            "description": "Phone (normalized to E.164). Expected final format:\n`^\\+[1-9]\\d{10,14}$` (between 11 and 15 digits after the `+`).\nAlso accepted: a positive JSON integer within the JavaScript safe range\n(at most 9007199254740991; every E.164 number fits), converted to a\nstring before normalization, for no-code connectors whose source field\nis numeric. Prefer the string: it is the only form that preserves an\nexplicit international `+`. Any other number (negative, decimal, zero)\nis rejected with a 422 `invalid_phone` that restates the accepted formats.\n",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 9007199254740991
              }
            ],
            "example": "+15145551234"
          },
          "sms_consent": {
            "description": "Explicit SMS consent. Must be `true` (boolean) or a\nwhitelisted truthy string (see the schema description).\nAmbiguous values (`false`, `\"no\"`, `0`, etc.) are rejected with\n422 `missing_consent`. Accepted at the root of the body or in\nthe `customData` object (GoHighLevel webhooks).\n",
            "oneOf": [
              {
                "type": "boolean",
                "enum": [
                  true
                ]
              },
              {
                "type": "string"
              }
            ],
            "example": true
          },
          "first_name": {
            "type": "string",
            "maxLength": 100,
            "example": "Alex"
          },
          "last_name": {
            "type": "string",
            "maxLength": 100,
            "example": "Tremblay"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "example": "alex@example.com"
          },
          "source_label": {
            "type": "string",
            "maxLength": 200,
            "description": "Free-form label identifying the lead's source (e.g. `funnel-fb-jan`,\n`landing-blueprint`). Shown in the pipeline and in the contact's\nactivity log.\n",
            "example": "funnel-fb-jan"
          },
          "utm": {
            "$ref": "#/components/schemas/LeadCaptureUtm"
          },
          "gclid": {
            "type": "string",
            "maxLength": 512,
            "description": "Google Ads click identifier (the `gclid` parameter of the landing\nURL, auto-tagging). Kept with the lead's ad\nattribution (first-touch). Without `utm.source`, the lead is\nattributed to the `google` source and the `cpc` medium, which\nlinks it to the campaign whose web origin is google / cpc.\nWhen `meta.ads_consent` is `false`, the identifier is never\nkept (neither in the attribution nor in the received copy): only the\ngoogle / cpc origin remains.\n",
            "example": "Cj0KCQjw5ZSWBhCVARIsALERCvzZ"
          },
          "notes": {
            "type": "string",
            "maxLength": 2000,
            "description": "Free-form notes attached to the lead (visible on the contact).",
            "example": "Lead chaud — a déjà essayé un concurrent"
          },
          "tags": {
            "type": "array",
            "maxItems": 20,
            "description": "Tags to attach to the created contact. If a tag does not exist for this\nclient, it is created on the fly. Any name that starts with\n\"Campagne :\" (any case and spacing) is ignored: this prefix\nis reserved for campaign tags, which Capturia sets on its own based on the\nlead's campaign. The response names it by its position in\n`ignored_fields` (`tags[1]`, reason `reserved_tag`).\n",
            "items": {
              "type": "string",
              "maxLength": 50
            },
            "example": [
              "lead-chaud",
              "vu-webinaire"
            ]
          },
          "qualification_answers": {
            "type": "array",
            "maxItems": 20,
            "description": "List of qualification questions/answers captured upstream.",
            "items": {
              "$ref": "#/components/schemas/LeadCaptureQualificationAnswer"
            }
          },
          "pipeline": {
            "$ref": "#/components/schemas/LeadCapturePipelineConfig"
          },
          "custom_fields": {
            "type": "object",
            "maxProperties": 50,
            "description": "Custom fields of the contact, keyed by the NAME of the definition\nconfigured on the client side (e.g. `type_propriete`), not by its internal\nUUID: a site's integrator does not know the ids. The capture resolves\nname → definition per client, then fills the contact and the kanban.\nA key with no matching definition is ignored (no error) and\nnamed in the response's `ignored_fields`\n(`custom_fields.ma_cle`, reason `custom_field_not_defined`). For a\n`select` field, provide the option's `value` (e.g. `maison_isolee`), not its\nlabel.\n",
            "propertyNames": {
              "maxLength": 100
            },
            "additionalProperties": {
              "type": [
                "string",
                "number",
                "boolean"
              ],
              "maxLength": 2000
            },
            "example": {
              "type_propriete": "maison_isolee",
              "delai_vente": "0_3_mois"
            }
          },
          "address": {
            "type": "string",
            "maxLength": 500,
            "description": "The contact's postal address (free text, e.g. the property for sale).",
            "example": "123 rue Principale, Montréal"
          },
          "postal_code": {
            "type": "string",
            "maxLength": 20,
            "description": "The contact's postal code.",
            "example": "H2X 1Y4"
          },
          "address_place_id": {
            "type": "string",
            "maxLength": 255,
            "description": "Place identifier (e.g. a Google Place ID) if the address comes from an\nautocomplete. Stored as is for later re-resolution.\n",
            "example": "ChIJDbdkHFQayUwR7-8fITgxTmU"
          },
          "street": {
            "type": "string",
            "maxLength": 255,
            "description": "Street (including the civic number). Completes an existing contact without overwriting a street that is already filled in.",
            "example": "123 rue Principale"
          },
          "city": {
            "type": "string",
            "maxLength": 120,
            "description": "City. Completes an existing contact without overwriting a city that is already filled in.",
            "example": "Montréal"
          },
          "sector": {
            "type": "string",
            "maxLength": 120,
            "description": "Area or neighborhood. Completes an existing contact without overwriting an area that is already filled in.",
            "example": "Plateau-Mont-Royal"
          },
          "province": {
            "type": "string",
            "maxLength": 100,
            "description": "Province. Completes an existing contact without overwriting a province that is already filled in.",
            "example": "QC"
          },
          "country": {
            "type": "string",
            "maxLength": 100,
            "description": "Country. Completes an existing contact without overwriting a country that is already filled in.",
            "example": "Canada"
          },
          "meta": {
            "$ref": "#/components/schemas/LeadCaptureMetaAttribution"
          },
          "system_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target System of the capture (UUID of one of the account's Systems, visible in\nthe Capturia dashboard). The captured lead is enrolled in this System.\nOmitted → the main System (historical behavior). A UUID that matches\nnone of the account's Systems → `system_not_found` error (422),\nand no contact is created. A System of the account that is not active (archived,\nfor example) → the lead goes to the main System, and the response names\n`system_id` in `ignored_fields` (reason `system_not_active`).\n",
            "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
          }
        }
      },
      "LeadCapturePipelineEcho": {
        "type": "object",
        "description": "Echo of the **resolved** pipeline routing: the requested pipeline (slug → the\naccount's pipeline and stage) when `pipeline.pipeline_slug` was provided, otherwise\nthe account's default pipeline, where a lead captured without routing is\nplaced. `null` only when no slug is provided AND the account has\nno default pipeline. On a contact created by the call\n(`contact_was_new: true`), this routing is the one applied to the contact. On\nan existing contact that was found (`contact_was_new: false`), the contact\nkeeps the pipeline and stage it already has (core fields are\nnever overwritten): the echo then reflects the resolved request, not necessarily\nthe contact's persisted state. When the contact did not take the requested\nrouting, `ignored_fields` says so: `pipeline.pipeline_slug` or\n`pipeline.stage_slug`, reason `existing_contact_kept`.\n",
        "required": [
          "pipeline_id",
          "pipeline_name"
        ],
        "properties": {
          "pipeline_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the targeted pipeline."
          },
          "pipeline_name": {
            "type": "string",
            "description": "Human-readable name of the targeted pipeline.",
            "example": "Ventes B2B"
          },
          "stage_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Identifier of the assigned stage (or the pipeline's default stage)."
          },
          "stage_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable name of the assigned stage.",
            "example": "Nouveau lead"
          }
        }
      },
      "LeadCaptureIgnoredField": {
        "type": "object",
        "description": "A received field that the capture did not take, and why. The lead is still\ncaptured: this is a setup warning, not an error.\n",
        "required": [
          "field",
          "reason"
        ],
        "properties": {
          "field": {
            "type": "string",
            "description": "Path of the field. For an unknown name or a discarded copy\n(`unknown_field`, `duplicate_field`), the path as received: a root\nname (`contact_id`), an unwrapped `customData` pair\n(`customData.order_bump`), a sub-field of a block, nested or flat\n(`pipeline.id`, `utm.utm_source`), or the key of a qualification answer\n(`qualification_answers[1].score`). For the other reasons, the canonical\npath, whatever form was received: `pipeline.stage_slug`,\n`system_id`, `custom_fields.budget`, or the position of a tag in\nthe cleaned-up list (`tags[1]`). The received name is capped at 64 characters;\na name shaped like an email address or a phone number comes back\nmasked (`[email]`, `[phone]`).\n",
            "example": "pipeline.id"
          },
          "reason": {
            "type": "string",
            "description": "- `unknown_field` — a field name the API does not recognize\n- `custom_field_not_defined` — a `custom_fields` key with no definition\n  of the same name in the account\n- `reserved_tag` — a tag name reserved for campaign tags\n- `system_not_active` — `system_id` of one of the account's Systems that is\n  not active (archived or in draft): the lead went to the main\n  System\n- `existing_contact_kept` — existing contact: its pipeline, stage,\n  value, closing date, or sales rep already in place were\n  kept\n- `sales_rep_not_found` — `pipeline.assigned_sales_rep_email` does not\n  match any active member of the account (see `assignment_status`)\n- `submission_not_recorded` — no submission recorded\n  (`existing_pipeline_contact`, `idempotent_replay`): tags,\n  notes, and qualification answers are not applied; a replay\n  also skips `source_label`, the ad attribution (`utm`,\n  `gclid`, the ad identity and the `ads_consent` choice of the\n  `meta` block, named one by one: `meta.ad_id`), and the routing (`system_id`).\n  The `meta` matching identifiers (`fbc`, `fbp`, `fbclid`,\n  `event_source_url`, `client_ip_address`, `client_user_agent`)\n  follow the first-contact rule instead, as on any existing\n  contact\n- `duplicate_field` — the same field arrived from several sources\n  (root, `customData`, alias, flat key) and another value was\n  kept\n",
            "enum": [
              "unknown_field",
              "custom_field_not_defined",
              "reserved_tag",
              "system_not_active",
              "existing_contact_kept",
              "sales_rep_not_found",
              "submission_not_recorded",
              "duplicate_field"
            ],
            "example": "unknown_field"
          }
        }
      },
      "LeadCaptureResponse": {
        "type": "object",
        "description": "Response of `POST /v1/leads/capture`. The `status` describes the outcome of the\n**submission**: recorded (`created`), idempotent replay\n(`idempotent_replay` — identical submission received within the\n5-minute window or via a known `Idempotency-Key`), or contact already being handled\n(`existing_pipeline_contact` — already in an open pipeline stage).\n`contact_was_new` distinguishes a contact created by the call from an\nexisting contact found by email or phone. In every case, the\nreturned contact ID is the same.\n\n`ignored_fields` names each received field that the capture did not take (see\nits description for its one limitation: identity and address fields\nalready filled in on an existing contact, and its ad attribution already\nset).\n\nThe SMS bot takes over within 30 seconds after a recorded\nsubmission if consent and configuration are in order. It is\nnever triggered for an `existing_pipeline_contact`.\n",
        "required": [
          "status",
          "contact_id",
          "activity_id",
          "sms_conversation_id",
          "pipeline",
          "original_created_at",
          "assignment_status",
          "contact_was_new",
          "ignored_fields",
          "ignored_fields_truncated",
          "message"
        ],
        "properties": {
          "status": {
            "type": "string",
            "description": "- `created` — submission recorded. Covers a contact created by\n  the call **and** an existing contact found (same email or same\n  phone) and then enriched; `contact_was_new` tells them apart.\n- `idempotent_replay` — submission already processed (5-min window or\n  Idempotency-Key match), no new lead created\n- `existing_pipeline_contact` — the contact is already in an open pipeline\n  stage (neither won nor lost), so it is already being handled:\n  no new submission is recorded and the SMS bot is not\n  triggered. Core fields and `custom_fields` still enrich\n  the existing contact; tags, notes, and qualification\n  answers, which belong to the submission, are not recorded\n  (`ignored_fields`, reason `submission_not_recorded`). Lets an\n  integration (Zapier, Make, FB Lead Ads) re-push a lead without\n  disrupting an ongoing conversation.\n",
            "enum": [
              "created",
              "idempotent_replay",
              "existing_pipeline_contact"
            ],
            "example": "created"
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the created or found contact."
          },
          "contact_was_new": {
            "type": "boolean",
            "description": "`true` only when the contact was created by this call.\n`false` when the capture found an existing contact (same email\nor same phone, secondary contact details included): the contact is\nenriched without overwriting its core fields; its pipeline, stage, and\nsales rep already in place are kept, even if the capture requested\ndifferent ones.\n"
          },
          "activity_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Identifier of the activity log entry created for this capture.\n`null` when `status = existing_pipeline_contact`: no submission\nis recorded for a contact already in an open pipeline stage.\n"
          },
          "sms_conversation_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Always `null` in the current response: the SMS bot starts\nasynchronously after the capture, so no conversation exists yet\nwhen the response is produced. Field kept for compatibility;\ndo not rely on it to detect that the bot has started.\n"
          },
          "pipeline": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/LeadCapturePipelineEcho"
              },
              {
                "type": "null"
              }
            ],
            "description": "Echo of the resolved pipeline routing: the requested pipeline, otherwise the\naccount's default pipeline. `null` when no slug is provided and\nthe account has no default pipeline. On an existing contact\n(`contact_was_new: false`), the contact keeps the pipeline it already\nhas; see `LeadCapturePipelineEcho`.\n"
          },
          "original_created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "For an `idempotent_replay`: timestamp of the original creation\n(useful to tell an immediate replay from a replay several\nminutes later). `null` for a fresh `created`.\n"
          },
          "assignment_status": {
            "type": "string",
            "description": "Result of the sales rep **resolution** if\n`pipeline.assigned_sales_rep_email` was provided:\n- `not_requested` — no assignment email provided\n- `assigned` — the requested sales rep exists and is active. They are assigned\n  to a contact created by the call; an existing contact already assigned\n  to someone else keeps its current sales rep (core field not\n  overwritten; see `contact_was_new`).\n- `email_not_found` — an email was provided but no active member with an\n  activated account matches it. The capture continues without assignment: a\n  contact created by the call starts with no sales rep, and an existing contact keeps\n  its assignment as is.\n",
            "enum": [
              "not_requested",
              "assigned",
              "email_not_found"
            ],
            "example": "assigned"
          },
          "ignored_fields": {
            "type": "array",
            "maxItems": 50,
            "description": "Received fields that the capture did not take, with their reason. Always\npresent, empty when everything was taken. At most 50 entries, sorted by\nusefulness: first the specific reasons (`existing_contact_kept`,\n`sales_rep_not_found`, `system_not_active`, `submission_not_recorded`,\n`reserved_tag`, `custom_field_not_defined`, `duplicate_field`), then\nunknown names: sub-fields of a block, `customData` pairs,\nroot keys. A connector that automatically adds dozens of\nfields at the root therefore never pushes a hand-mapped field out of\nthe list.\n\nOne limitation: on an existing contact, the list names the fields of the\n`pipeline` block it kept, but not, one by one, its identity and address\nfields already filled in, nor its ad attribution\nalready set (first-contact rule), which are kept as well\n(`contact_was_new: false`).\n\nWith an `Idempotency-Key` already seen, the original response is replayed\nas is, list included.\n",
            "items": {
              "$ref": "#/components/schemas/LeadCaptureIgnoredField"
            }
          },
          "ignored_fields_truncated": {
            "type": "boolean",
            "description": "`true` when the capture ignored more fields than the list\nnames (cap of 50).\n"
          },
          "message": {
            "type": "string",
            "description": "Human-readable message describing the result (useful for logs/debugging). When\n`ignored_fields` is not empty, it ends with\n\"N received field(s) were not applied — see ignored_fields.\", or\nwith \"More than N received fields were not applied — see\nignored_fields.\" when the list is truncated.\n",
            "example": "Lead captured. The SMS bot will take over within 30 seconds if consent and configuration are in order."
          }
        }
      },
      "EventIngestContact": {
        "type": "object",
        "additionalProperties": false,
        "description": "Identity of the contact the event targets. At least one identifier is\nrequired among `external_id`, `email`, `phone`. The server resolves the contact\nin the order `external_id`+`external_source` → `phone` → `email`; the identity\nfields you send enrich the contact non-destructively (a\nvalue that is already set is never overwritten).\n",
        "properties": {
          "external_id": {
            "type": "string",
            "maxLength": 255,
            "description": "Identifier of the contact in the source system (e.g. a HubSpot ID, a\nuser ID from your backend). Source idempotency: two events\nwith the same `external_id` + `external_source` always resolve\nto the same contact.\n"
          },
          "external_source": {
            "type": "string",
            "maxLength": 100,
            "description": "Namespace of the `external_id` (e.g. `hubspot`, `shopify`,\n`mon-backend`). Default: `api`. Ignored without `external_id`.\n"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          },
          "phone": {
            "type": "string",
            "description": "Accepted formats: E.164 (`+15145551234`), 10-digit NANP,\n11 digits with country code, or formatted variants — normalized\nto E.164 on the server.\n"
          },
          "first_name": {
            "type": "string",
            "maxLength": 100
          },
          "last_name": {
            "type": "string",
            "maxLength": 100
          },
          "create_if_missing": {
            "type": "boolean",
            "default": true,
            "description": "`false` = never create a contact: if no contact matches the\nidentifiers you send, the request returns `404 contact_not_found` and\nnothing is recorded.\n"
          }
        }
      },
      "EventIngestPayload": {
        "type": "object",
        "additionalProperties": false,
        "description": "Strict contract: an unknown top-level key (or one inside `contact`) is\nrejected with `422 validation_error` — this protects you from silent\nintegration typos. Free-form data goes under `payload`.\n",
        "required": [
          "event",
          "contact"
        ],
        "properties": {
          "event": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Event name, in the account's namespace. Compared as-is\n(case-sensitive) to the name configured on the automations'\n\"Custom event\" trigger.\n",
            "example": "abandon_panier"
          },
          "contact": {
            "$ref": "#/components/schemas/EventIngestContact"
          },
          "payload": {
            "type": "object",
            "additionalProperties": true,
            "description": "Arbitrary event data. Each top-level key is\navailable to automation messages through `{{event.<key>}}` (a\nnested object is serialized as JSON). The stored content is capped\n(number of keys, depth, total size).\n"
          },
          "custom_fields": {
            "type": "object",
            "additionalProperties": true,
            "description": "Custom fields of the contact, keyed by the definition's `name`\n(same contract as `/v1/leads/capture`). A key with no matching\ndefinition is silently ignored. Additive merge: the\nnew value wins on collision.\n"
          },
          "source_label": {
            "type": "string",
            "maxLength": 200,
            "description": "Free-form label identifying the source (shown on the contact)."
          }
        }
      },
      "EventIngestResponse": {
        "type": "object",
        "required": [
          "status",
          "contact_id",
          "contact_created",
          "contact_archived",
          "activity_id",
          "enrolled_executions",
          "event",
          "message"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ingested"
            ]
          },
          "contact_id": {
            "type": "string",
            "format": "uuid"
          },
          "contact_created": {
            "type": "boolean",
            "description": "`true` if a contact was created by this call."
          },
          "contact_archived": {
            "type": "boolean",
            "description": "`true` if the identifiers only match an archived contact:\nthe event is recorded on the contact (for traceability) but the contact\nis not modified and no automation is enrolled.\n"
          },
          "activity_id": {
            "type": "string",
            "format": "uuid",
            "description": "`custom_event_received` activity recorded on the contact."
          },
          "enrolled_executions": {
            "type": "integer",
            "description": "Number of automations enrolled by this event. `0` if no\nactive automation listens for this event name (the event is\nstill recorded on the contact).\n"
          },
          "event": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "EventRegistrationCreate": {
        "type": "object",
        "description": "Body of `POST /v1/event-registrations`. The contact must exist in the\naccount (`POST /v1/contacts` or `POST /v1/leads/capture` create it, along with\nits consents). You identify the event by `event_id` OR by\n`event_slug`, the address at the end of its registration link — never\nboth.\n",
        "required": [
          "contact_id"
        ],
        "additionalProperties": false,
        "properties": {
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Contact to register.",
            "example": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
          },
          "event_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the event."
          },
          "event_slug": {
            "type": "string",
            "maxLength": 80,
            "description": "Address of the event, as it appears in its registration link.",
            "example": "defi-5-jours"
          },
          "edition_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target edition. If omitted: the event's next open edition,\nas on its registration page.\n"
          },
          "language": {
            "type": "string",
            "enum": [
              "fr",
              "en"
            ],
            "description": "Language of the messages. If omitted: the event's first language.\n",
            "example": "fr"
          }
        }
      },
      "EventRegistration": {
        "type": "object",
        "description": "Registration of a contact for an edition of one of the account's events (webinar,\nchallenge, summit, online event). One registration per person and\nper edition.\n",
        "required": [
          "id",
          "event_id",
          "edition_id",
          "contact_id",
          "language",
          "created"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the registration.",
            "example": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d"
          },
          "event_id": {
            "type": "string",
            "format": "uuid",
            "description": "Event of the registration.",
            "example": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d"
          },
          "edition_id": {
            "type": "string",
            "format": "uuid",
            "description": "Edition (cohort, date) the contact is registered for.",
            "example": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9"
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Registered contact.",
            "example": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
          },
          "language": {
            "type": "string",
            "enum": [
              "fr",
              "en"
            ],
            "description": "Language of the event's messages for this person. A language the\nevent does not offer is replaced by the event's first language.\n",
            "example": "fr"
          },
          "created": {
            "type": "boolean",
            "description": "`true` if the call created the registration, `false` if the contact was already\nregistered for this edition (the same registration is returned).\n",
            "example": true
          }
        }
      },
      "Cursor": {
        "type": [
          "string",
          "null"
        ],
        "description": "Opaque base64url cursor. Clients must not parse this value.",
        "example": "eyJ2IjoxLCJmIjoiY3JlYXRlZF9hdCIsInMiOiJkZXNjIiwidmFsIjoiMjAyNi0wNS0wMlQxNDozMDowMFoiLCJpZCI6ImFiYy0xMjMifQ"
      },
      "Contact": {
        "type": "object",
        "description": "Contact (lead or customer) belonging to a Capturia account. Any nullable\nfield may be missing in the database. `custom_fields` is a free-form object validated\nagainst the account's custom field definitions.\n",
        "required": [
          "id",
          "email",
          "is_priority",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the contact.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Primary email of the contact (lowercase, deduplicated per account).",
            "example": "alex@example.com"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "E.164 phone number (e.g. `+15145551234`).",
            "example": "+15145551234"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Tremblay"
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Source of the contact: `manual` (entered in Capturia), `api` (created by\nan integration via `POST /v1/contacts`), `ai_assistant` (created by an\nAI assistant connected to the connector), or the origin of a capture\n(form, booking, inbound call…).\n",
            "example": "api"
          },
          "pipeline_stage_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Current stage in the sales pipeline. `null` if the contact isn't in the pipeline."
          },
          "deal_value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Estimated value of the opportunity in CAD.",
            "example": 2500
          },
          "is_priority": {
            "type": "boolean",
            "description": "Flagged as a priority for faster human follow-up.",
            "default": false
          },
          "custom_fields": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Account-defined custom fields. Validated on write against\n`client_custom_field_definitions`. Additive on read (no fixed schema).\n"
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full address as entered or validated (Google). The components\nbelow are split out of it when the address has been validated; `city` and\n`region` can also be set on their own (form, import).\n",
            "example": "73 rue Bérard, Gatineau, QC J8T 5R8"
          },
          "street": {
            "type": [
              "string",
              "null"
            ],
            "description": "Street number and name.",
            "example": "73 rue Bérard"
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "City.",
            "example": "Gatineau"
          },
          "sector": {
            "type": [
              "string",
              "null"
            ],
            "description": "Sector or neighborhood (e.g. borough).",
            "example": "Hull"
          },
          "region": {
            "type": [
              "string",
              "null"
            ],
            "description": "Administrative region, derived automatically from the city (Québec).",
            "example": "Outaouais"
          },
          "province": {
            "type": [
              "string",
              "null"
            ],
            "description": "Province or state.",
            "example": "QC"
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "example": "Canada"
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ],
            "example": "J8T 5R8"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "Pagination": {
        "type": "object",
        "required": [
          "next_cursor",
          "has_more",
          "limit",
          "cursor",
          "total_count"
        ],
        "properties": {
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque cursor to pass as `?cursor=` for the next page. `null` on the last page.\nFormat: base64url encoding of an object `{v:1, f:string, s:asc|desc, val:string, id:string}`.\nClients must not parse this value — treat it as opaque.\n",
            "example": "eyJ2IjoxLCJmIjoiY3JlYXRlZF9hdCIsInMiOiJkZXNjIiwidmFsIjoiMjAyNi0wNS0wMlQxNDozMDowMFoiLCJpZCI6ImFiYy0xMjMifQ"
          },
          "has_more": {
            "type": "boolean",
            "description": "True if there is at least one more page."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "description": "Maximum number of results per page requested (default 20, max 100).",
            "example": 20
          },
          "cursor": {
            "type": [
              "string",
              "null"
            ],
            "deprecated": true,
            "description": "Legacy field identical to `next_cursor` during the transition window (sunset 2027-05-03).\nUse `next_cursor` instead.\n"
          },
          "total_count": {
            "type": "integer",
            "deprecated": true,
            "description": "Legacy field. Estimated total — may differ from the actual count on large tables (PostgreSQL `estimated`).\nSunset 2027-05-03. An exact count isn't guaranteed and is expensive to compute.\n",
            "minimum": 0
          }
        }
      },
      "ContactCreate": {
        "type": "object",
        "description": "Body for creating a contact (`POST /v1/contacts`). An email or a\nphone number is required. A person who's already known (email or\nphone number, secondary identities included) returns 409\n`duplicate_contact` with the existing contact, rather than a new insert.\n",
        "anyOf": [
          {
            "required": [
              "email"
            ]
          },
          {
            "required": [
              "phone"
            ]
          }
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Email of the contact, stored in lowercase. An uppercase letter never creates a second contact.",
            "example": "alex@example.com"
          },
          "first_name": {
            "type": "string",
            "maxLength": 200,
            "example": "Alex"
          },
          "last_name": {
            "type": "string",
            "maxLength": 200,
            "example": "Tremblay"
          },
          "phone": {
            "type": "string",
            "maxLength": 50,
            "pattern": "[0-9]",
            "description": "Phone number of the contact (at most 50 characters): a reachable number\nthat can be normalized to E.164 (e.g. `+15145551234`). A number that's too\nshort, incomplete, or followed by an extension is rejected (`400`,\n`validation_error`, reason `invalid_phone` on the `phone` field),\njust like when updating a contact.\n",
            "example": "+15145551234"
          },
          "company": {
            "type": "string",
            "maxLength": 300,
            "example": "Acme Inc."
          },
          "deal_value": {
            "type": "number",
            "minimum": 0,
            "description": "Estimated value of the opportunity in CAD.",
            "example": 2500
          },
          "custom_fields": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Custom fields, keyed by the field's **name** (or the id of its\ndefinition). Validated against `client_custom_field_definitions` on\nwrite — an unknown field, an incompatible type, or a value\nthat's too long returns 400 `invalid_custom_fields`, under the key\nyou sent. Maximum lengths: text 1,000 characters, long text\n4,000, URL 2,048 (`http(s)://`), email 320, phone 30; a\nlist only accepts one of its options. On read, the keys\nreturned are the definition ids (storage form).\n"
          },
          "system_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target System of the created contact (UUID of one of the account's Systems). The contact\nis engaged in this System. Omitted → main System (historical\nbehavior). UUID unknown for this account → `system_not_found` error\n(422), no contact created; archived System → silent fallback to the\nmain System.\n",
            "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
          }
        }
      },
      "ContactWithTags": {
        "description": "Contact enriched with its tags (response of `GET /v1/contacts/{id}?expand=tags`).\n`client_contact_tags` is the join table and contains the full nested tag.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Contact"
          },
          {
            "type": "object",
            "properties": {
              "client_contact_tags": {
                "type": "array",
                "description": "Tag↔contact links, with the full tag nested to avoid a round-trip.",
                "items": {
                  "type": "object",
                  "required": [
                    "tag_id",
                    "client_tags"
                  ],
                  "properties": {
                    "tag_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "client_tags": {
                      "type": "object",
                      "required": [
                        "id",
                        "name",
                        "color"
                      ],
                      "properties": {
                        "id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "name": {
                          "type": "string",
                          "example": "Lead chaud"
                        },
                        "color": {
                          "type": "string",
                          "description": "Hexadecimal color of the tag (`#` prefix).",
                          "example": "#ef4444"
                        },
                        "system_key": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "Stable key of a tag managed by Capturia (`campaign:…` for a\ncampaign's tag), `null` for an account tag.\n"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "ContactUpdate": {
        "type": "object",
        "description": "Body for updating a contact (`PATCH /v1/contacts/{id}`). All fields\nare optional, but at least one must be provided. `custom_fields` is merged\nwith the existing values (not replaced).\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "example": "alex@example.com"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Tremblay"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\+[1-9]\\d{1,14}$",
            "description": "E.164 phone number (e.g. `+15145551234`).",
            "example": "+15145551234"
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "deal_value": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "example": 2500
          },
          "is_priority": {
            "type": "boolean",
            "description": "Flagged as a priority for faster human follow-up."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-form internal notes about the contact."
          },
          "custom_fields": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Additive patch on the existing custom fields (keys you don't\nprovide are kept). Passing `null` clears all fields.\n"
          }
        }
      },
      "ContactBatchItem": {
        "type": "object",
        "description": "One contact in the `POST /v1/contacts/batch` batch. The email is required:\nit serves as the upsert and deduplication key within the batch. The target System\nis set once for the whole batch (top-level `system_id`), never per\ncontact; custom fields can only be sent through `POST /v1/contacts`.\n",
        "required": [
          "email"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Email of the contact, compared case-insensitively.",
            "example": "alex@example.com"
          },
          "first_name": {
            "type": "string",
            "maxLength": 150,
            "example": "Alex"
          },
          "last_name": {
            "type": "string",
            "maxLength": 150,
            "example": "Tremblay"
          },
          "phone": {
            "type": "string",
            "maxLength": 40,
            "description": "Phone number of the contact, normalized to E.164 (e.g. `+15145551234`).",
            "example": "+15145551234"
          },
          "company": {
            "type": "string",
            "maxLength": 200,
            "example": "Acme Inc."
          },
          "deal_value": {
            "type": "number",
            "minimum": 0,
            "description": "Estimated value of the opportunity in CAD.",
            "example": 2500
          }
        }
      },
      "Conversation": {
        "type": "object",
        "description": "A chat thread (web chat or third-party API) that belongs to a Capturia account.\nBacked by the `chat_sessions` table — a thread groups a visitor's messages\nwith an AI or human agent. Contact details (email, phone, first_name, last_name)\nare snapshotted on the session; `contact_id` points to the unified contact when\na match has been found.\n\nThreads are created and messages are sent from the front end / chat SDK — the public API\nis read-only.\n",
        "required": [
          "id",
          "message_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the conversation.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Visitor's first name (snapshotted when the thread opened).",
            "example": "Alex"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Tremblay"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "example": "alex@example.com"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Visitor phone number (E.164 if captured).",
            "example": "+15145551234"
          },
          "agent_slug": {
            "type": [
              "string",
              "null"
            ],
            "description": "Slug of the AI agent that replies in this conversation.",
            "example": "support-ventes"
          },
          "agent_identity_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Identifier of the specific AI agent that replies in this conversation (an\naccount can have several chatbots of the same type). `null` for a\nthread without an agent.\n",
            "example": "2c7d1e4a-5b6f-4a8c-9d0e-1f2a3b4c5d6e"
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Thread source (URL, funnel label, \"widget\", etc.).",
            "example": "widget"
          },
          "message_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Total number of messages in the thread.",
            "example": 12
          },
          "last_message_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 UTC timestamp of the last message. `null` if there are no messages.",
            "example": "2026-05-04T12:34:56.789Z"
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Associated unified contact, if an email/phone match has been found."
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "Message": {
        "type": "object",
        "description": "A single message in a conversation (`GET /v1/conversations/{id}/messages`).\nSorted in natural reading order (created_at ASC). The role indicates the\nmessage direction; `author_kind` tells you who wrote it.\n",
        "required": [
          "id",
          "role",
          "content",
          "created_at",
          "author_kind",
          "author_name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the message.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "role": {
            "type": "string",
            "description": "Message direction:\n- `user`: visitor / lead\n- `assistant`: reply to the visitor, from the AI agent or from a sales rep\n  on the team (see `author_kind`)\n- `system`: system message (init, context, instructions)\n",
            "enum": [
              "user",
              "assistant",
              "system"
            ],
            "example": "user"
          },
          "content": {
            "type": "string",
            "description": "Text content of the message, ready to display (assistant messages may\ncontain light Markdown). The agent's internal control markers are removed:\na message the agent sent as several bubbles is rendered as separate\nparagraphs. It can therefore be an empty string if the message only\ncarried control markers; the row is still returned so the pagination\nhas no gaps.\n",
            "example": "Bonjour, j'aimerais en savoir plus sur vos services."
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "author_kind": {
            "type": [
              "string",
              "null"
            ],
            "description": "Who wrote the message:\n- `visitor`: the visitor / lead\n- `agent`: the AI agent\n- `human`: a sales rep on the team who replied to the visitor from\n  the Inbox (their first name is in `author_name`)\n\n`null` for a system message.\n",
            "enum": [
              "visitor",
              "agent",
              "human",
              null
            ],
            "example": "visitor"
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The sales rep's first name as the visitor saw it, frozen at send time. Present\nonly when `author_kind` is `human`; otherwise `null`.\n",
            "example": null
          }
        }
      },
      "Booking": {
        "type": "object",
        "description": "Bookings in the schedule of the API key's account: the appointments\nyour team holds on your calendars (booking page, dashboard, AI agents,\nautomations, integrations). A key only sees its own account's\nbookings.\n\nThe API lets you list, book (through the same service as the booking\npage and the dashboard, with the same rules), and cancel (soft cancel:\n`status=\"cancelled\"` + `cancelled_at`). There is no PATCH: to change\nthe time, cancel and book again, or reschedule from the dashboard.\n",
        "required": [
          "id",
          "event_type_id",
          "contact_id",
          "host_id",
          "guest_name",
          "guest_email",
          "guest_phone",
          "start_datetime",
          "end_datetime",
          "timezone",
          "status",
          "source",
          "notes",
          "meeting_link",
          "cancelled_at",
          "cancel_reason",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the booking.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "event_type_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Calendar the booking is made on.",
            "example": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e"
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The lead's contact (a contact of the account) the booking belongs to.",
            "example": "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"
          },
          "host_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Team member who holds the booking (host).",
            "example": "5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f"
          },
          "guest_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Guest's name at the time of booking.",
            "example": "Alex Tremblay"
          },
          "guest_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Guest's email at the time of booking.",
            "example": "alex@example.com"
          },
          "guest_phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Guest's phone number, if known.",
            "example": "+15145551234"
          },
          "start_datetime": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the booking (ISO 8601).",
            "example": "2026-11-10T14:00:00+00:00"
          },
          "end_datetime": {
            "type": "string",
            "format": "date-time",
            "description": "End of the booking (ISO 8601); the duration is the calendar's.",
            "example": "2026-11-10T14:30:00+00:00"
          },
          "timezone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Guest's IANA time zone (e.g. `America/Montreal`).",
            "example": "America/Montreal"
          },
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "cancelled",
              "completed",
              "no_show"
            ],
            "description": "Booking status:\n- `confirmed` — booked and active (value set on creation through the API)\n- `cancelled` — cancelled\n- `completed` — held\n- `no_show` — the guest did not show up\n",
            "example": "confirmed"
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Booking source: `api` for a booking made through the API or the\nconnector; other values: `booking_page`, `pipeline`, `manual`, `chat`,\n`automation`.\n",
            "example": "api"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-form notes attached to the booking."
          },
          "meeting_link": {
            "type": [
              "string",
              "null"
            ],
            "description": "Meeting link (video conference) when the calendar provides one.",
            "example": "https://meet.google.com/abc-defg-hij"
          },
          "cancelled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cancellation timestamp. `null` until the booking is cancelled."
          },
          "cancel_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cancellation reason, if one was given."
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Last modification; `null` if the booking has never changed."
          }
        }
      },
      "BookingCreate": {
        "type": "object",
        "description": "Body of `POST /v1/bookings`. The booking is made on one of the\naccount's calendars (`event_type_id`), at the requested time\n(`start_datetime`) — never another. The duration is the calendar's:\n`end_datetime` is rejected. `status` and `source` are set by the\nserver (`confirmed` / `api`).\n\nThe guest is either an existing contact of the account (`contact_id`)\nor `guest_name` + `guest_email`: the contact is then found by its\ncontact details, or created, as on the booking page. With both, the\ndetails you provide take precedence for this booking.\n",
        "required": [
          "event_type_id",
          "start_datetime"
        ],
        "additionalProperties": false,
        "properties": {
          "event_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Active calendar of the account. An unknown or inactive calendar, or\none from another account, is rejected with `404 resource_not_found`\n(`details.field = \"event_type_id\"`).\n",
            "example": "2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d7e"
          },
          "start_datetime": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the booking, ISO 8601. The offset is optional: without an\noffset, the time is read as UTC. Prefer an explicit offset\n(`2026-11-10T10:00:00-05:00`).\n",
            "example": "2026-11-10T10:00:00-05:00"
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Existing contact of the account. An unknown contact or one from\nanother account is rejected with `404 resource_not_found`\n(`details.field = \"contact_id\"`); an archived contact, `422`\n(`contact_archived`); a contact without an email and without\n`guest_email`, `400` (`contact_without_email`).\n",
            "example": "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"
          },
          "guest_name": {
            "type": "string",
            "maxLength": 200,
            "description": "Guest's full name. Required without `contact_id`.",
            "example": "Alex Tremblay"
          },
          "guest_email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "description": "Guest's email. Required without `contact_id`.",
            "example": "alex@example.com"
          },
          "guest_phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 30,
            "description": "Guest's phone number (E.164 recommended). Taken into account with `guest_name` + `guest_email`.",
            "example": "+15145551234"
          },
          "host_id": {
            "type": "string",
            "format": "uuid",
            "description": "Team member who should hold the booking. The requested host is\nhonored or rejected with its reason, never replaced: unknown to the\naccount, `404 resource_not_found` (`details.field = \"host_id\"`); not a\nhost of this calendar, `422 host_not_on_calendar`; busy or outside\ntheir hours, `422 host_unavailable`. Without `host_id`: the contact's\nsales rep if they can take the slot, otherwise the calendar's\nrotation.\n",
            "example": "5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f"
          },
          "timezone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Guest's IANA time zone (e.g. `America/Montreal`). If absent: the\ncontact's, otherwise the calendar's. An unknown time zone or a bare\noffset (`-04:00`) is rejected: `400 validation_error`, reason\n`invalid_timezone`.\n",
            "example": "America/Montreal"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Free-form notes."
          },
          "form_responses": {
            "type": "object",
            "description": "Answers to the calendar's form: one entry per question, under the\nquestion's identifier, in the form `{ \"value\": ... }`. `value` is\ntext, a number for a numeric question, one of the options for a\nchoice question, or a list of options for a multiple-choice\nquestion. A calendar with a required question rejects a booking\nwithout its answer: `400 validation_error`,\n`details.field = \"form_responses.<id>\"`. Every form rejection\nincludes `details.questions`, the calendar's questions (`id`, `label`,\n`type`, `required`, `options`).\n",
            "additionalProperties": {
              "type": "object",
              "required": [
                "value"
              ],
              "additionalProperties": true,
              "properties": {
                "value": {
                  "description": "The answer to the question.",
                  "oneOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "number"
                    },
                    {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  ]
                }
              }
            },
            "example": {
              "budget": {
                "value": "10 000 $ à 25 000 $"
              }
            }
          }
        }
      },
      "BookingCancelResult": {
        "type": "object",
        "description": "Response of `DELETE /v1/bookings/{id}` — soft cancel. The booking is\nnot deleted: its status changes to `cancelled` and `cancelled_at` is\ntimestamped.\n",
        "required": [
          "id",
          "status",
          "cancelled_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "description": "Always `cancelled` after a successful cancellation.",
            "enum": [
              "cancelled"
            ]
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 timestamp of the cancellation."
          }
        }
      },
      "Quote": {
        "type": "object",
        "description": "A sales quote (`client_quotes`) addressed to a prospect. Lifecycle:\n`draft` → `sent` → `signed` (terminal) or `cancelled`. Archived quotes\n(`archived_at IS NOT NULL`) are excluded from every list and from PATCH/SEND\noperations — the public API does not expose them.\n\nAmounts (`subtotal`, `tps_amount`, `tvq_amount`, `total`) are computed\nserver-side from the blocks (the quote's line items) and cannot be edited via PATCH.\nCreating a quote through the API initializes an empty quote (`blocks=[]`) in `draft` status;\nyou add blocks and finalize the quote from the dashboard.\n",
        "required": [
          "id",
          "client_id",
          "quote_number",
          "status",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the quote.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "Capturia account that owns the quote."
          },
          "quote_number": {
            "type": "string",
            "description": "Human-readable quote number (unique per account, assigned by the caller).",
            "example": "DEV-2026-0042"
          },
          "status": {
            "type": "string",
            "description": "Quote lifecycle:\n- `draft` — created, not sent yet\n- `sent` — sent to the prospect (`sent_at` timestamped)\n- `signed` — signed by the prospect (`signed_at` timestamped, terminal)\n- `cancelled` — cancelled (terminal for sending)\n",
            "enum": [
              "draft",
              "sent",
              "signed",
              "cancelled"
            ],
            "example": "draft"
          },
          "prospect_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex Tremblay"
          },
          "prospect_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "example": "alex@example.com"
          },
          "prospect_phone": {
            "type": [
              "string",
              "null"
            ],
            "example": "+15145551234"
          },
          "prospect_company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "prospect_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "The prospect's free-form mailing address (multiple lines allowed)."
          },
          "subtotal": {
            "type": [
              "number",
              "null"
            ],
            "description": "Subtotal before taxes, in CAD. Computed server-side from the blocks.",
            "example": 1000
          },
          "tps_amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "GST (TPS) amount, computed from the linked tax profile.",
            "example": 50
          },
          "tvq_amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "QST (TVQ) amount, computed from the linked tax profile.",
            "example": 99.75
          },
          "total": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total including taxes, in CAD (`subtotal + tps_amount + tvq_amount`).",
            "example": 1149.75
          },
          "tax_profile_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Applied tax profile; `null` = the account's default profile (otherwise GST/QST)."
          },
          "tax_exempt_reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Written reason for a tax-exempt sale. `null` = the quote is taxed at the rates\nresolved server-side. Non-null = no taxes (amounts recalculated to 0), and the reason is\nshown to the prospect below the total. A quote with a positive amount, no taxes,\nAND no reason can be neither sent (`422 untaxed_quote`) nor paid.\n"
          },
          "valid_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Date until which the offer is valid (`YYYY-MM-DD`).",
            "example": "2026-06-30"
          },
          "payment_terms": {
            "type": [
              "string",
              "null"
            ],
            "description": "Payment terms (free text, e.g. `Net 30`)."
          },
          "payment_plan_type": {
            "type": "string",
            "enum": [
              "full",
              "deposit",
              "installments",
              "recurring"
            ],
            "description": "Payment plan: `full` (paid in full), `deposit` (deposit, then installments),\n`installments` (N equal installments), `recurring` (the total billed every month\nuntil the sales rep stops it).\n",
            "example": "full"
          },
          "payment_deposit_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "percent",
              "fixed",
              null
            ],
            "description": "Deposit type (`deposit` only) — a percentage of the total or a fixed amount."
          },
          "payment_deposit_value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Deposit value (`deposit` only) — a percentage or dollars, depending on `payment_deposit_type`."
          },
          "payment_installments_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Number of installments after the initial payment (`deposit` from 1 to 36, `installments` from 2 to 36)."
          },
          "payment_interval_days": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Interval between two installments, in days."
          },
          "payment_execution_mode": {
            "type": "string",
            "enum": [
              "automatic",
              "manual"
            ],
            "description": "How installments are collected: `automatic` (off-session card charge\nafter the first online payment) or `manual` (the customer pays each\ninstallment by bank transfer/Interac and the sales rep records it — nothing is charged).\n",
            "example": "automatic"
          },
          "signature_payment_order": {
            "type": "string",
            "enum": [
              "signature_then_payment",
              "payment_then_signature",
              "payment_only",
              "signature_only"
            ],
            "description": "Signature ↔ payment order on the quote's public page:\n`signature_then_payment` (sign first, then pay — default),\n`payment_then_signature` (pay first; the signature completes the file),\n`payment_only` (sale without a contract, no signature requested),\n`signature_only` (nothing to pay: the amount is presented as complimentary, and\nsigning is the only step). Snapshot of the account setting taken at\ncreation; editable while `status=\"draft\"`.\n",
            "example": "signature_then_payment"
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Timestamp of the first send to the prospect. `null` while `status=\"draft\"`."
          },
          "first_viewed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "First time the prospect opened the quote (tracking pixel/page view)."
          },
          "signed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Signature timestamp. `null` while `status != \"signed\"`."
          },
          "converted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Timestamp of the FIRST recorded payment (online card payment, plan\ncharge, approved manual payment). `null` until a payment has been\nreceived. Historical milestone: a refund does not reset it to `null`. It is\nthe completed-sale indicator for `signature_payment_order=\"payment_only\"`\n(no signature is ever recorded there: `status` stays `sent`/`viewed`, and\n`signed_at` stays `null`).\n"
          },
          "sales_rep_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Sales rep assigned to the quote (a member of the account's team)."
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "QuoteCreate": {
        "type": "object",
        "description": "Request body for creating a quote (`POST /v1/quotes`). Creates a quote with `status=\"draft\"`.\nCapturia assigns the number (`quote_number`): providing one is rejected\n(400 `validation_error`, reason `read_only`). Omitted fields take the\naccount's defaults (validity, payment terms, tax profile).\n",
        "additionalProperties": false,
        "properties": {
          "contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The account's contact (`/v1/contacts`) to link the quote to. Each omitted\n`prospect_*` field is read from the contact; each field you provide takes precedence. A contact\nfrom another account → 400 `validation_error` (reason `not_found_in_tenant`).\n"
          },
          "prospect_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex Tremblay"
          },
          "prospect_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "example": "alex@example.com"
          },
          "prospect_phone": {
            "type": [
              "string",
              "null"
            ],
            "example": "+15145551234"
          },
          "prospect_company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "prospect_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-form mailing address."
          },
          "language": {
            "type": "string",
            "enum": [
              "fr",
              "en"
            ],
            "description": "Language of the quote (public page, PDF, sending email). Defaults to `fr`."
          },
          "valid_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Validity deadline (`YYYY-MM-DD`). Omitted: the account's default validity\n(in days, starting today, Montreal time). `null`: no date.\n",
            "example": "2026-06-30"
          },
          "payment_terms": {
            "type": [
              "string",
              "null"
            ],
            "description": "Payment terms (free text). Omitted: the account's terms. `null`: none.\n",
            "example": "Net 30"
          },
          "line_items": {
            "type": "array",
            "maxItems": 200,
            "description": "The quote's line items, written in the same operation; the server computes the amounts.",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "description"
              ],
              "properties": {
                "description": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 2000
                },
                "quantity": {
                  "type": "number",
                  "exclusiveMinimum": 0,
                  "description": "Quantity; defaults to 1."
                },
                "unit_price": {
                  "type": "number",
                  "minimum": 0,
                  "description": "Unit price before taxes; defaults to 0."
                },
                "unit_label": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Displayed unit; when omitted, the default unit in the quote's language."
                },
                "is_taxable": {
                  "type": "boolean",
                  "description": "Taxable line; defaults to `true`."
                },
                "catalog_item_id": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uuid",
                  "description": "Service from the account's catalog that the line is based on."
                }
              }
            }
          },
          "internal_notes": {
            "type": "string",
            "description": "Internal notes, not visible to the prospect."
          },
          "sales_rep_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Assigned sales rep; `null` or omitted = none."
          },
          "tax_profile_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Applied tax profile (GST/QST); `null` or omitted = the account's default profile."
          },
          "signature_payment_order": {
            "type": "string",
            "enum": [
              "signature_then_payment",
              "payment_then_signature",
              "payment_only",
              "signature_only"
            ],
            "description": "Signature ↔ payment order on the public page. Omitted → the account's default\nsetting (snapshot taken at creation). Value outside the list → 400 `validation_error`.\n"
          },
          "tax_exempt_reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Written reason for a tax-exempt sale (see `Quote.tax_exempt_reason`)."
          }
        }
      },
      "QuoteUpdate": {
        "type": "object",
        "description": "Request body for updating a quote (`PATCH /v1/quotes/{id}`). At least one field is\nrequired, otherwise 400 `no_fields`. Amounts (`subtotal`, `total`, etc.) are\nnot editable — they are computed server-side from the blocks (managed from the dashboard).\n\n`status` only closes the quote (`declined`, `cancelled`); sending goes\nthrough `POST /v1/quotes/{id}/send`.\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "prospect_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "prospect_phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_company": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_address": {
            "type": [
              "string",
              "null"
            ]
          },
          "valid_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "payment_terms": {
            "type": [
              "string",
              "null"
            ]
          },
          "internal_notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "sales_rep_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "tax_profile_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "payment_plan_type": {
            "type": "string",
            "enum": [
              "full",
              "deposit",
              "installments",
              "recurring"
            ],
            "description": "Payment plan structure. The amount of each installment is derived\nserver-side. The plan is locked once the first payment is collected (409 `payment_plan_locked`).\nConsistency between fields (a deposit with no value, an installment count out of bounds)\nis validated server-side → 400 `validation_error`.\n"
          },
          "payment_deposit_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "percent",
              "fixed",
              null
            ]
          },
          "payment_deposit_value": {
            "type": [
              "number",
              "null"
            ]
          },
          "payment_installments_count": {
            "type": [
              "integer",
              "null"
            ]
          },
          "payment_interval_days": {
            "type": [
              "integer",
              "null"
            ]
          },
          "payment_execution_mode": {
            "type": "string",
            "enum": [
              "automatic",
              "manual"
            ],
            "description": "`automatic` = installments are charged to the card off-session; `manual` = payments\nreceived (bank transfer, Interac) are recorded by the sales rep, and nothing is charged. Value\noutside the list → 400 `validation_error`.\n"
          },
          "signature_payment_order": {
            "type": "string",
            "enum": [
              "signature_then_payment",
              "payment_then_signature",
              "payment_only",
              "signature_only"
            ],
            "description": "Signature ↔ payment order on the public page. Editable while the quote is\na draft; once it has been sent → 409 `signature_payment_order_locked`. Value\noutside the list → 400 `validation_error`.\n"
          },
          "tax_exempt_reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Written reason for a tax-exempt sale; `null` makes the quote taxed again. An\nempty string or one longer than 200 characters → 400 `validation_error`. Totals\nare recalculated server-side when it changes.\n"
          },
          "status": {
            "type": "string",
            "enum": [
              "declined",
              "cancelled"
            ],
            "description": "Closes the quote: `declined` (the lead declined it) or `cancelled`. The other\nstatuses are never set through PATCH: `sent` comes from\n`POST /v1/quotes/{id}/send` (the email goes out), `viewed` and `signed` from the\nlead, `expired` from the validity date. Value outside the list → 400\n`validation_error`. A signed or paid quote can no longer be closed → 409\n`status_locked` (nothing is written).\n"
          }
        }
      },
      "QuoteSendResult": {
        "type": "object",
        "description": "Response from `POST /v1/quotes/{id}/send`: the quote was emailed to the\nlead, and `status=\"sent\"` and `sent_at` are set.\n",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "object",
            "description": "Partial snapshot of the quote after the update (a subset of fields compared to the\nfull `Quote` — the handler does not reselect every field).\n",
            "required": [
              "id",
              "client_id",
              "quote_number",
              "status",
              "sent_at",
              "created_at",
              "updated_at"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "client_id": {
                "type": "string",
                "format": "uuid"
              },
              "quote_number": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "sent"
                ],
                "description": "Always `sent` on success."
              },
              "prospect_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "prospect_email": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "email"
              },
              "sent_at": {
                "type": "string",
                "format": "date-time",
                "description": "Send timestamp (just set by the request)."
              },
              "created_at": {
                "$ref": "#/components/schemas/Timestamp"
              },
              "updated_at": {
                "$ref": "#/components/schemas/Timestamp"
              }
            }
          }
        }
      },
      "Tag": {
        "type": "object",
        "description": "Tag (`client_tags`) that can be attached to a contact or another client resource.\nListed in stable alphabetical order (`name ASC, id ASC`). The color is\nfree-form (hexadecimal by convention) and the category lets you group\nseveral tags (e.g. `priority`, `industry`, `lifecycle`).\n",
        "required": [
          "id",
          "name",
          "color",
          "system_key",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the tag.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "name": {
            "type": "string",
            "description": "Display name of the tag, unique within the account.",
            "example": "Lead chaud"
          },
          "color": {
            "type": "string",
            "description": "Display color. Hexadecimal convention `#RRGGBB`. Server default\n`#6b7280` (neutral gray) if not provided at creation.\n",
            "example": "#ef4444"
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-form description (purpose of the tag, criteria for applying it)."
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Grouping category (e.g. `priority`, `industry`, `lifecycle`).",
            "example": "priority"
          },
          "system_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Stable key of a tag that Capturia applies and keeps up to date, `null` for a\ntag created by the account. A key that starts with `campaign:` identifies\na campaign's tag (\"Campagne : <name>\"): Capturia applies it to\nevery lead of that campaign (campaign number, tag, or\nweb source) and removes it when the lead's campaign changes. The\nname follows the campaign's name, but the key never changes: a third-party\ntool can rely on it to count a campaign's leads\n(`GET /v1/contacts?tag=<id>`, paging until\n`has_more: false`, never through `pagination.total_count`, which is\nonly an estimate). These tags cannot be applied or\nremoved through the API (`409 tag_managed_by_capturia`).\n",
            "example": "campaign:pn:5b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "TagCreate": {
        "type": "object",
        "description": "Body for creating a tag (`POST /v1/tags`). Only `name` is required.\n`color` defaults to `#6b7280` on the server if omitted.\n",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "description": "Display name of the tag.",
            "example": "Lead chaud"
          },
          "color": {
            "type": "string",
            "description": "Hexadecimal color `#RRGGBB`. Server default: `#6b7280`.",
            "example": "#ef4444"
          },
          "description": {
            "type": "string",
            "description": "Free-form description."
          },
          "category": {
            "type": "string",
            "description": "Grouping category.",
            "example": "priority"
          }
        }
      },
      "Automation": {
        "type": "object",
        "description": "Client automation (`client_automations`) — an IF/THEN workflow triggered by an\nevent (lead captured, stage change, booking made, etc.) or by an explicit\nAPI call (`POST /v1/automations/{id}/trigger`). An archived automation\n(`archived_at IS NOT NULL`) is excluded from the public list and can no longer\nbe triggered.\n\nThe public API exposes read access (list + execution history) and the\nmanual trigger. Creating, editing and archiving an automation happen in the\ndashboard — there is no POST/PATCH/DELETE `/v1/automations`.\n",
        "required": [
          "id",
          "name",
          "trigger_type",
          "is_active",
          "execution_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the automation.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "name": {
            "type": "string",
            "description": "Human-readable name of the automation (dashboard label).",
            "example": "Relance lead inactif 7 jours"
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Free-form grouping category (e.g. `nurturing`, `qualification`, `onboarding`).",
            "example": "nurturing"
          },
          "trigger_type": {
            "type": "string",
            "description": "Trigger event type. Typical values: `lead_captured`,\n`stage_changed`, `booking_created`, `tag_added`, `manual`, `webhook`.\nNon-exhaustive list — managed in the dashboard.\n",
            "example": "lead_captured"
          },
          "action_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Main action type the automation runs (e.g. `send_email`,\n`send_sms`, `assign_to_seller`, `create_task`). `null` for\nmulti-action automations defined as a node graph.\n",
            "example": "send_email"
          },
          "is_active": {
            "type": "boolean",
            "description": "When `false`, the automation is disabled — event triggers\nare ignored and `POST /v1/automations/{id}/trigger` returns\n409 `automation_inactive`.\n",
            "example": true
          },
          "last_executed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 UTC timestamp of the last execution (`null` if never triggered).",
            "example": "2026-05-04T12:34:56.789Z"
          },
          "execution_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Cumulative count of the automation's executions.",
            "example": 42
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "AutomationTriggerInput": {
        "type": "object",
        "description": "Body for manually triggering an automation\n(`POST /v1/automations/{id}/trigger`). Creates an execution with `status=\"pending\"`\nand `trigger_type=\"manual_api\"`, ready to be claimed by the automation\nengine. The automation must have `is_active=true` (otherwise\n409 `automation_inactive`), be published (otherwise 409 `automation_not_published`)\nand have a trigger node (otherwise 422 `no_trigger`); the `contact_id`\nmust belong to the calling client (otherwise 404 `not_found`).\n",
        "required": [
          "contact_id"
        ],
        "additionalProperties": false,
        "properties": {
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target contact to run the automation on. Must belong to the calling client.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "event_data": {
            "type": "object",
            "additionalProperties": true,
            "description": "Optional event context (free-form JSON object) injected into\nthe execution and available to the graph's template variables.\nEmpty by default.\n",
            "example": {
              "source": "crm_import",
              "campaign": "spring-2026"
            }
          }
        }
      },
      "AutomationTriggerResult": {
        "type": "object",
        "description": "Response of `POST /v1/automations/{id}/trigger`. The API records a\n`pending` execution (claimable by the engine) — processing happens asynchronously\nin the automation engine (no guarantee of synchronous\ncompletion). To track progress, call\n`GET /v1/automations/{id}/history`.\n",
        "required": [
          "id",
          "automation_id",
          "contact_id",
          "trigger_type",
          "status",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the created execution.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "automation_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target automation (mirrors the `{id}` in the path)."
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Target contact (mirrors the body)."
          },
          "trigger_type": {
            "type": "string",
            "description": "Always `manual_api` for triggers made through this endpoint.",
            "enum": [
              "manual_api"
            ],
            "example": "manual_api"
          },
          "status": {
            "type": "string",
            "description": "Initial status on creation — always `pending` (then moves to `running`/`completed`/`failed`).",
            "enum": [
              "pending"
            ],
            "example": "pending"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "AutomationHistoryEntry": {
        "type": "object",
        "description": "Execution history entry for an automation\n(`client_automation_executions`, exposed through\n`GET /v1/automations/{id}/history`). An execution is one run\nof the automation for a given contact. The engine can restart a\nfailed execution (`retry_count > 0`).\n",
        "required": [
          "id",
          "automation_id",
          "contact_id",
          "trigger_type",
          "status",
          "retry_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the execution.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "automation_id": {
            "type": "string",
            "format": "uuid",
            "description": "Automation this execution belongs to."
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Contact the automation ran on."
          },
          "trigger_type": {
            "type": "string",
            "description": "What triggered the run: `manual_api` (through this endpoint),\n`manual_dashboard` (from the dashboard), or the name of the trigger\nevent (`lead_captured`, `stage_changed`, etc.).\n",
            "example": "manual_api"
          },
          "status": {
            "type": "string",
            "description": "Execution status. Typical values: `pending`, `running`, `paused`,\n`completed`, `failed`, `cancelled`. The list is managed by the engine — not an enum\nhere so it can evolve freely.\n",
            "example": "completed"
          },
          "current_node_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Identifier of the current node in the execution graph (for\nmulti-step automations). `null` for single-action automations.\n"
          },
          "scheduled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Scheduled timestamp for the execution (used for delays and scheduling)."
          },
          "execution_log": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Structured JSON log of the steps taken, the decisions made and\nthe errors encountered. Free-form schema — depends on the automation type.\n"
          },
          "retry_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of attempts made (0 if it succeeded on the first try).",
            "example": 0
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "Deal": {
        "type": "object",
        "description": "A sales pipeline opportunity (deal). A deal is NOT a separate entity\nin the database: it is a `client_contacts` row with a non-NULL\n`pipeline_stage_id`. You can therefore work with the same resource\nthrough `/v1/contacts/{id}` AND `/v1/pipeline/deals/{id}` — the deals endpoint\nonly filters on and writes the `pipeline_stage_id` field.\n\nThe set of exposed fields is intentionally limited to what matters\nfor the pipeline view (contact details + stage + value + score).\nFor the full contact (custom_fields, tags, notes), use\n`/v1/contacts/{id}`.\n\nAvailable endpoints: `GET /v1/pipeline/deals` (list, filterable by\n`?stage_id`), `POST /v1/pipeline/deals` (places a contact in a stage),\n`PATCH /v1/pipeline/deals/{id}` (moves it to another stage),\n`DELETE /v1/pipeline/deals/{id}` (removes it from the pipeline without deleting the\ncontact). **No `GET /v1/pipeline/deals/{id}` is exposed** — retrieve an\nindividual deal via `GET /v1/contacts/{id}` or via the\n`?stage_id=...` filter on the list.\n",
        "required": [
          "id",
          "pipeline_stage_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the contact/deal (key shared with `client_contacts`).",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "The contact's primary email.",
            "example": "alex@example.com"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "E.164 phone number.",
            "example": "+15145551234"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Tremblay"
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "pipeline_stage_id": {
            "type": "string",
            "format": "uuid",
            "description": "The deal's current stage in the pipeline. NOT NULL in the deals list\n(filter `pipeline_stage_id IS NOT NULL`). A DELETE sets it to NULL,\nand the contact then disappears from the deals list.\n"
          },
          "deal_value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Estimated value of the opportunity in CAD.",
            "example": 2500
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "updated_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "DealCreate": {
        "type": "object",
        "description": "Body for creating a deal (`POST /v1/pipeline/deals`). Places an existing\ncontact in a pipeline stage. **Does not create a contact** — the\n`contact_id` must already exist and belong to the calling account (otherwise\n404 `not_found`). The same applies to `stage_id` (404 `not_found` if the stage\ndoes not belong to the account).\n\nIf the contact is already in a stage, its `pipeline_stage_id` is overwritten\nwith the new value — no movement history is recorded\n(the `pipeline_history` table references the other internal `contacts` table,\nnot `client_contacts`).\n",
        "required": [
          "contact_id",
          "stage_id"
        ],
        "additionalProperties": false,
        "properties": {
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Contact to place in the pipeline. Must belong to the calling account.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "stage_id": {
            "type": "string",
            "format": "uuid",
            "description": "Destination stage. Must belong to the calling account.",
            "example": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
          }
        }
      },
      "DealRemoveResult": {
        "type": "object",
        "description": "Response from `DELETE /v1/pipeline/deals/{id}`. The contact is NOT deleted:\nits `pipeline_stage_id` is set to NULL, which removes it from the deals list\nwithout touching the contact entity (which remains accessible via `/v1/contacts/{id}`).\n",
        "required": [
          "id",
          "removed_from_pipeline"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the deal/contact removed from the pipeline."
          },
          "removed_from_pipeline": {
            "type": "boolean",
            "description": "Always `true` on success — confirms that the removal took place.",
            "enum": [
              true
            ],
            "example": true
          }
        }
      },
      "DealUpdate": {
        "type": "object",
        "description": "Body for moving a deal (`PATCH /v1/pipeline/deals/{id}`). Only the\nstage move is exposed through this endpoint — to change the\ndeal's other fields (email, deal_value, etc.), use\n`PATCH /v1/contacts/{id}`.\n\n`stage_id` is required (400 `validation_error` otherwise). The target stage\nmust belong to the account (404 `not_found` otherwise). The target deal must already\nbe in the pipeline (404 `not_found` if `pipeline_stage_id IS NULL`).\n",
        "required": [
          "stage_id"
        ],
        "additionalProperties": false,
        "properties": {
          "stage_id": {
            "type": "string",
            "format": "uuid",
            "description": "Destination stage. Must belong to the calling account.",
            "example": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
          }
        }
      },
      "Stage": {
        "type": "object",
        "description": "A stage in an account's sales pipeline (`client_pipeline_stages`). A\nstage defines a step in the sales journey (e.g. `New lead`,\n`Qualified`, `Proposal sent`, `Won`, `Lost`). An account can have\nseveral pipelines: each stage names its own (`pipeline`), and two\nstages from different pipelines can share the same name. Sorted by\npipeline (in dashboard order), then by `stage_order ASC`.\n\nThe public API is read-only here\n(`GET /v1/pipeline/stages`) — creating, editing, and reordering stages\nis done in the dashboard. The response is not paginated (all of an\naccount's stages usually fit under 20 entries) but uses a\n`list V2`-compatible format with `pagination.next_cursor=null`,\n`pagination.has_more=false`, and `pagination.limit` equal to the number of stages\nreturned.\n\nEach stage includes a `contact_count` computed in real\ntime — the number of the account's contacts currently in that\nstage, excluding archived and merged contacts, just like the Pipeline screen\n(useful for pipeline KPIs). A stage hidden in the dashboard\n(`is_hidden`) is still listed: its contacts exist and are counted.\n",
        "required": [
          "id",
          "name",
          "slug",
          "color",
          "stage_order",
          "is_default",
          "is_won",
          "is_lost",
          "created_at",
          "contact_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the stage.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "name": {
            "type": "string",
            "description": "Display name shown on the pipeline.",
            "example": "Qualifié"
          },
          "slug": {
            "type": "string",
            "description": "Stable slug identifying the stage by convention (used as a stable reference).",
            "example": "qualified"
          },
          "color": {
            "type": "string",
            "description": "Hexadecimal display color `#RRGGBB`.",
            "example": "#3b82f6"
          },
          "stage_order": {
            "type": "integer",
            "description": "Position of the stage in the pipeline (ascending, left → right).",
            "example": 2
          },
          "is_default": {
            "type": "boolean",
            "description": "Default stage where new contacts land (typically `New lead`).",
            "example": false
          },
          "is_won": {
            "type": "boolean",
            "description": "Marks the stage as a terminal \"won\" stage (used for conversion calculations).",
            "example": false
          },
          "is_lost": {
            "type": "boolean",
            "description": "Marks the stage as a terminal \"lost\" stage (used for churn calculations).",
            "example": false
          },
          "probability": {
            "type": [
              "number",
              "null"
            ],
            "description": "Closing probability associated with the stage (0-100), used to weight the forecast.",
            "example": 50
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "contact_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of the account's contacts currently in this stage,\nexcluding archived and merged contacts. Computed in real time on every\ncall — not cached.\n",
            "example": 17
          },
          "is_hidden": {
            "type": "boolean",
            "description": "Stage hidden on the dashboard pipeline. Its contacts exist and\nare counted; a tool does not offer it as a destination.\n",
            "example": false
          },
          "pipeline_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Identifier of the stage's pipeline.",
            "example": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d"
          },
          "pipeline": {
            "type": [
              "object",
              "null"
            ],
            "description": "The stage's pipeline, as named in the dashboard.",
            "required": [
              "id",
              "name",
              "is_default"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid",
                "description": "Identifier of the pipeline.",
                "example": "5c1e9a7b-2d3f-4a6b-8c9d-0e1f2a3b4c5d"
              },
              "name": {
                "type": "string",
                "description": "Name of the pipeline.",
                "example": "Pipeline"
              },
              "is_default": {
                "type": "boolean",
                "description": "The account's default pipeline.",
                "example": true
              }
            }
          }
        }
      },
      "EmailConversation": {
        "type": "object",
        "description": "An email thread (`client_email_conversations`) between the Capturia account and a\ncontact. A thread groups every message exchanged on the same subject\nwith the same recipient. Sorted by `last_message_at DESC` (most recent\nfirst).\n\nThe public API is read-only here\n(`GET /v1/emails/conversations`) — to send an email, use\n`POST /v1/emails/send` (which creates the message and the conversation server-side).\nReading a conversation's individual messages is not yet\navailable through the public API (planned for a later phase —\ntoday the payload is limited to the thread's metadata).\n",
        "required": [
          "id",
          "sender_id",
          "status",
          "unread_count",
          "last_message_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the conversation.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Unified contact associated with this conversation. `null` if the recipient\nhasn't been matched to a `client_contacts` row yet (unknown inbound email\nor not yet reconciled).\n"
          },
          "sender_id": {
            "type": "string",
            "format": "uuid",
            "description": "Member of the account's team (`client_users`) who started or owns the\nconversation. Determines the sending mailbox on the Resend side.\n"
          },
          "subject": {
            "type": [
              "string",
              "null"
            ],
            "description": "Conversation subject (from the first message). `null` for threads without a subject.",
            "example": "Suivi de notre échange"
          },
          "status": {
            "type": "string",
            "description": "Conversation status for the team (reading/triage). Typical\nvalues: `open`, `closed`, `archived`. The list is managed by the platform —\nnot an enum here so it can evolve.\n",
            "example": "open"
          },
          "unread_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of messages in the conversation that the account's team hasn't read.",
            "example": 2
          },
          "last_message_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 UTC timestamp of the last message (inbound or outbound).\nUsed as the thread's primary sort key. NOT NULL in the table.\n",
            "example": "2026-05-04T12:34:56.789Z"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "EmailSendPayload": {
        "type": "object",
        "description": "Body for sending a transactional email (`POST /v1/emails/send`). The email\nis queued with `status=\"queued\"`, `priority=1`, `type=\"transactional\"`\nand delivered by Capturia's email worker (Resend) — the 201 response\nconfirms the email was queued, not that it was delivered.\n\nThe sender (`from`) is resolved server-side from the account's default\nsender (`client_email_senders.is_default = true`) — it is not\naccepted in the body. If no default sender is configured:\n400 `no_sender_configured`.\n\n`Idempotency-Key` is NOT supported on this route — a naive\nretry will create a second send.\n",
        "required": [
          "to",
          "subject",
          "body"
        ],
        "additionalProperties": false,
        "properties": {
          "to": {
            "type": "string",
            "format": "email",
            "description": "Email recipient. Validated against the standard email pattern\n(`^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`). An invalid format returns\n400 `validation_error`.\n",
            "example": "alex@example.com"
          },
          "subject": {
            "type": "string",
            "description": "Email subject (free text, no explicit length cap in the API).",
            "example": "Confirmation de votre commande"
          },
          "body": {
            "type": "string",
            "description": "Email body in HTML. The content isn't validated —\nthe sending worker sanitizes it before\nhanding it to Resend for delivery.\n",
            "example": "<p>Bonjour Alex,</p><p>Votre commande est confirmée.</p>"
          },
          "test": {
            "type": "boolean",
            "default": false,
            "description": "Test mode. When `true`, the send is treated as an operational\ntest: personalization variables (`{{first_name}}`,\netc.) are filled with sample values, the subject is\nprefixed with `[TEST]`, and recipient compliance checks\n(unsubscribe, consent) are bypassed.\n\nIn exchange, two safeguards apply:\n\n- the recipient (`to`) must be an active member of the account's\n  team — otherwise 400 `recipient_not_allowed`;\n- a quota of 50 test sends per rolling 24 hours, shared with\n  the platform's test buttons — otherwise 429\n  `test_quota_exceeded`.\n\nA test send creates neither a conversation nor any history on a\ncontact.\n",
            "example": false
          }
        }
      },
      "EmailSendResult": {
        "type": "object",
        "description": "201 response from `POST /v1/emails/send`. The email is queued —\n`status=\"queued\"` on creation. Final delivery (opens, bounces,\netc.) is tracked separately by the email worker and isn't exposed by\nthis endpoint.\n",
        "required": [
          "id",
          "status",
          "to_email",
          "subject",
          "created_at",
          "is_test"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the send (`client_email_sends.id`).",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "status": {
            "type": "string",
            "description": "Initial status on creation — always `queued`.",
            "enum": [
              "queued"
            ],
            "example": "queued"
          },
          "to_email": {
            "type": "string",
            "format": "email",
            "description": "Email recipient (mirrors the body).",
            "example": "alex@example.com"
          },
          "subject": {
            "type": "string",
            "description": "Email subject (mirrors the body; prefixed with `[TEST]` in test mode).\n",
            "example": "Confirmation de votre commande"
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          },
          "is_test": {
            "type": "boolean",
            "description": "`true` when the send was created in test mode (the body's `test` field).\n",
            "example": false
          }
        }
      },
      "Webhook": {
        "type": "object",
        "description": "Outbound webhook — an HTTPS endpoint on your side that Capturia calls when a\nsubscribed event occurs. A webhook created through the API belongs to both\nthe account AND the API key that created it: `GET /v1/webhooks` only shows\nthe webhooks of the current key (webhooks created by another key on the same\naccount, or from the dashboard, are neither visible nor deletable\nhere).\n\nThe HMAC secret used to sign outbound payloads is only\nreturned on creation (`POST`) and then encrypted in the database. It can't be\nread again afterward — to rotate it, delete the webhook and\ncreate a new one.\n",
        "required": [
          "id",
          "url",
          "events",
          "is_active",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique identifier of the subscription.",
            "example": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL called to notify you of subscribed events.",
            "example": "https://hooks.example.com/capturia"
          },
          "events": {
            "type": "array",
            "description": "List of subscribed events. See `WebhookCreate.events` for the allowed values.",
            "items": {
              "type": "string"
            },
            "example": [
              "lead_created",
              "payment_received"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "If `false`, the subscription is disabled (events are no longer\ndelivered to the URL). Disabling happens in the platform — there is no\npublic API endpoint to toggle this flag.\n",
            "example": true
          },
          "created_at": {
            "$ref": "#/components/schemas/Timestamp"
          }
        }
      },
      "WebhookCreate": {
        "type": "object",
        "description": "Body for creating a webhook subscription (`POST /v1/webhooks`). The URL\nmust use HTTPS — HTTP is rejected to avoid leaking payloads\nin cleartext. Maximum of 10 active subscriptions per account (beyond that:\n429 `subscription_limit_reached`).\n",
        "required": [
          "url",
          "events"
        ],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL that will receive the notifications. Must be a publicly\nreachable endpoint that accepts JSON `POST` requests.\n",
            "example": "https://hooks.example.com/capturia"
          },
          "events": {
            "type": "array",
            "description": "Non-empty list of business events to subscribe to, or `*` for all\nevents. Each event name is delivered as-is in the\n`X-Capturia-Event` header and the payload's `event` field. Full catalog and\npayload schemas: `docs/webhooks-event-catalog.md`.\n",
            "minItems": 1,
            "items": {
              "type": "string",
              "enum": [
                "*",
                "login",
                "logout",
                "password_changed",
                "member_invitation_sent",
                "member_invitation_accepted",
                "member_role_changed",
                "member_status_changed",
                "member_removed",
                "payout_paid",
                "lead_created",
                "lead_updated",
                "lead_qualified",
                "lead_stage_changed",
                "lead_merged",
                "lead_unmerged",
                "lead_deleted",
                "deal_started",
                "sale_won",
                "note_created",
                "task_created",
                "task_completed",
                "tag_created",
                "tag_deleted",
                "stage_created",
                "stage_deleted",
                "booking_created",
                "booking_cancelled",
                "booking_rescheduled",
                "booking_no_show",
                "payment_received",
                "payment_failed",
                "payment_refunded",
                "sms_received",
                "sms_sent",
                "email_received",
                "email_sent",
                "chat_message_received",
                "chat_message_sent",
                "call_ended",
                "contact_unsubscribed",
                "sequence_created",
                "automation_created",
                "automation_toggled",
                "automation_deleted",
                "automation_restored",
                "automation_completed",
                "quote_created",
                "quote_sent",
                "quote_status_changed",
                "quote_deleted",
                "proposal_sent",
                "proposal_viewed",
                "proposal_paid",
                "proposal_lost",
                "contract_sent",
                "contract_opened",
                "contract_signed",
                "contract_revoked",
                "integration_connected",
                "integration_disconnected",
                "integration_status_changed",
                "seats_updated",
                "domain_added",
                "domain_removed",
                "permissions_updated",
                "agent_updated"
              ]
            },
            "example": [
              "lead_created",
              "payment_received"
            ]
          }
        }
      },
      "WebhookCreated": {
        "description": "Response from `POST /v1/webhooks` — a `Webhook` plus the HMAC `secret` in\ncleartext, returned ONLY ONCE on creation. Store it\nimmediately: you use it to verify the signature of outbound payloads\n(`X-Capturia-Signature`), and the API never returns it again.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Webhook"
          },
          {
            "type": "object",
            "required": [
              "secret"
            ],
            "properties": {
              "secret": {
                "type": "string",
                "description": "HMAC secret in cleartext (64 hex characters, 256 bits of entropy).\nStored encrypted server-side — it can't be retrieved after this\nresponse. Used to sign outbound payloads.\n",
                "example": "8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3"
              }
            }
          }
        ]
      },
      "PresuasionSubmissionAnswer": {
        "type": "object",
        "description": "Formatted answer to a question from the pre-suasion tool. Used to\nbuild the human-readable block shown in the contact's timeline in the\nCapturia dashboard (`Q: ...` / `R: ...`).\n",
        "required": [
          "question",
          "answer"
        ],
        "additionalProperties": false,
        "properties": {
          "question": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "example": "Quel est ton défi principal en ventes ?"
          },
          "answer": {
            "type": "string",
            "maxLength": 2000,
            "example": "Je perds des leads parce que je ne suis pas assez rapide à les rappeler."
          }
        }
      },
      "PresuasionSubmissionPayload": {
        "type": "object",
        "description": "Submission body for a pre-suasion form\n(`POST /v1/presuasion-submissions`).\n\n**Legacy convention — internal integration endpoint that does not follow\nthe v1 conventions.** The pre-suasion gateway (`demo.capturia.io`) calls\nthis endpoint when a visitor completes a pre-suasion tool form. It does\nnot use a Bearer API key (public route, rate-limited by IP), accepts a\ncustom body format, and returns a custom body format\n(`{success, contact_id, ...}` instead of `{data: ...}`).\n\nThe target account is resolved server-side through `proposition_slug` →\n`presuasion_instances.client_id` (`client_id` is never accepted from the\nbody). The contact is upserted on `(lower(email), client_id)` with\nadditive enrichment (existing non-null fields are never overwritten).\n",
        "required": [
          "proposition_slug",
          "tool_id",
          "tool_name",
          "responses",
          "formatted_answers"
        ],
        "additionalProperties": false,
        "properties": {
          "proposition_slug": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Slug of the pre-suasion instance (`presuasion_instances.slug`).\nUsed to resolve the target Capturia account — client_id is never read\nfrom the body.\n",
            "example": "blueprint-ventes-2026"
          },
          "tool_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Identifier of the tool in the gateway catalog (the tool type, shared by\nall of its proposals). Informational only: the timeline activity\nidentifies the tool by the proposal resolved from `proposition_slug`.\n",
            "example": "calculateur-roi"
          },
          "tool_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Human-readable name of the pre-suasion tool (shown in the timeline).",
            "example": "Calculateur de ROI"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Visitor's email (deduplication key for the contact). Required, EXCEPT\nwhen `identity_confirmed: true` comes with a valid `token` — the contact\nis then resolved through the tokenized link (rule enforced server-side,\nwhich JSON Schema cannot express: with neither an email nor an identity\nconfirmed by token, the server responds 400).\n",
            "example": "alex@example.com"
          },
          "first_name": {
            "type": "string",
            "maxLength": 200,
            "description": "Optional first name — enriches the contact if missing.",
            "example": "Alex"
          },
          "last_name": {
            "type": "string",
            "maxLength": 200,
            "description": "Optional last name — enriches the contact if missing.",
            "example": "Tremblay"
          },
          "phone": {
            "type": "string",
            "maxLength": 50,
            "description": "Optional phone number — enriches the contact if missing. No format validation.",
            "example": "+15145551234"
          },
          "address": {
            "type": "string",
            "maxLength": 500,
            "description": "Formatted address of the property (Google Places or free-text entry).\nWritten additively to the structured `client_contacts.address` column:\nit only fills the column while it is still empty (never overwritten).\n",
            "example": "123 Rue Sainte-Catherine, Montréal, QC H2X 1K4, Canada"
          },
          "postal_code": {
            "type": "string",
            "maxLength": 20,
            "description": "Postal code extracted by Google Places (Canada). Enriches `client_contacts.postal_code` if empty.",
            "example": "H2X 1K4"
          },
          "address_place_id": {
            "type": "string",
            "maxLength": 300,
            "description": "Google Place ID of the address — Maps deep link and disambiguation. Enriches `client_contacts.address_place_id` if empty.",
            "example": "ChIJDbdkHFQayUwR7-8fITgxTmU"
          },
          "responses": {
            "type": "object",
            "additionalProperties": true,
            "description": "Raw form data (free-form key/value pairs). Stored as is in\n`metadata.responses` of the created activity.\n",
            "example": {
              "budget": "500-1000",
              "timeline": "3-mois"
            }
          },
          "formatted_answers": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "Structured Question/Answer list used to build the human-readable block\nshown in the contact's timeline.\n",
            "items": {
              "$ref": "#/components/schemas/PresuasionSubmissionAnswer"
            }
          },
          "result_data": {
            "type": "object",
            "additionalProperties": true,
            "description": "Data computed by the pre-suasion tool (e.g. score, recommendation,\ncategory). Stored as is in `metadata.result_data`.\n"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Completion timestamp on the gateway side. If missing, the server uses\n`now()`.\n",
            "example": "2026-05-04T12:34:56.789Z"
          },
          "turnstile_token": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "Cloudflare Turnstile token produced by the invisible widget of the demo\nrunner (`demo.capturia.io` and unified custom domains). Verified\nserver-side through siteverify with an `idempotency_key` equal to the\nbody's `submission_id` (stable across retries — a token is single-use,\nand Cloudflare replays the original verdict for the same key), falling\nback to the request's `request_id` when `submission_id` is missing.\nOptional in log-only mode; required when\n`PRESUASION_TURNSTILE_REQUIRED=true` (403 response otherwise).\n"
          },
          "submission_id": {
            "type": "string",
            "format": "uuid",
            "description": "Idempotency key generated by the gateway, stable across retries of the\nsame submission. If a `presuasion_submission` activity already exists\nwith this `submission_id` for the resolved account, the server returns\nthe existing activity — before any quota count and any write — instead\nof creating a second one (prevents duplicates on a transient retry, e.g.\na proxy timeout after a server-side success). Uniqueness is guaranteed\nin the database by a partial unique index: two concurrent submissions\nwith the same `submission_id` produce a single activity.\n",
            "example": "b3f1c2a4-5d6e-4f70-8a91-2c3d4e5f6071"
          },
          "token": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Token of the personalized pre-suasion link (`presuasion_link_tokens`),\npassed by the gateway when the visitor arrives through a link sent to a\nknown contact. Used for token-first attribution of the contact and for\nthe link's lifecycle. Never used after an `identity_declined`.\n",
            "example": "tok_abcdefabcdefabcdefabcdef"
          },
          "session_key": {
            "type": "string",
            "format": "uuid",
            "description": "Anonymous key of the visit (`presuasion_tool_sessions.session_key`),\ngenerated by the runner. Ties the submission to its visit: replay of\nthe identity denial, session completion, and a `metadata.session_id`\ntrace on the activity (funnel analytics cohort).\nMissing = submission without visit tracking (\"unlinked\" lead).\n",
            "example": "6f1f5e0a-2b3c-4d5e-8f9a-0b1c2d3e4f5a"
          },
          "identity_confirmed": {
            "type": "boolean",
            "description": "The visitor confirmed being the recipient of the tokenized link\n(\"That's me\"). With `token`, the contact is resolved through the link\nand `email` becomes optional. Mutually exclusive with\n`identity_declined` (400 otherwise).\n",
            "example": true
          },
          "identity_declined": {
            "type": "boolean",
            "description": "The visitor stated they are NOT the recipient of the link\n(\"That's not me\"). The token never attributes the contact, the denial\nis replayed server-side before completion, and `email` becomes required\nagain. Mutually exclusive with `identity_confirmed` (400 otherwise).\n",
            "example": false
          },
          "custom_domain_id": {
            "type": "string",
            "description": "Custom domain identifier (`client_domains.id`) resolved server-side by\nthe gateway proxy from the submission's host. Used ONLY to disambiguate\nthe proposal when the same active slug exists in several domain buckets.\nMissing = shared bucket (demo.capturia.io,\n`custom_domain_id IS NULL`). Never used to resolve client_id (always\nderived from the proposal on the server side).\n",
            "example": "a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809"
          },
          "ads_consent": {
            "type": "string",
            "enum": [
              "granted",
              "denied"
            ],
            "description": "The visitor's choice in the tool's cookie banner (Quebec Law 25) at\ncapture time. Governs the `Lead` event sent to the account's Meta pixel\nthrough the Conversions API: on a surface with a banner, only `granted`\nallows sending; `denied` is persisted on the contact and blocks any\nlater sending; missing = no choice expressed, nothing is sent.\n",
            "example": "granted"
          },
          "source_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048,
            "description": "URL of the tool page at capture time (Meta `event_source_url`). When\n`utm` and `gclid` are missing, its `utm_*` parameters and its Google Ads\nclick identifier serve as a fallback for ad attribution. Best effort: a\nvalue that is not an absolute http(s) URL is ignored (and logged), never\nrejected — a measurement field never makes the submission fail.\n",
            "example": "https://app.laprofduweb.com/la-boussole"
          },
          "fbp": {
            "type": "string",
            "maxLength": 200,
            "description": "Meta `_fbp` browser cookie, passed to Meta as is — kept only when\n`ads_consent` is `granted`.\n",
            "example": "fb.1.1700000000000.1234567890"
          },
          "fbc": {
            "type": "string",
            "maxLength": 500,
            "description": "Meta `_fbc` click cookie (or derived from `fbclid`), passed to Meta as\nis — kept only when `ads_consent` is `granted`.\n",
            "example": "fb.1.1700000000000.AbCdEfGh"
          },
          "visitor_ip": {
            "type": "string",
            "maxLength": 64,
            "description": "Visitor's IP address, set by the demo submission proxy (the IP seen by\nthis route is then the proxy's). Used for Meta matching only. Honored\nonly when the request carries the proxy's HMAC signature; without a\nsignature, the request's own IP prevails and this value is ignored.\n",
            "example": "203.0.113.7"
          },
          "visitor_user_agent": {
            "type": "string",
            "maxLength": 1024,
            "description": "Visitor's user agent, set by the demo submission proxy. Used for Meta\nmatching only. Same rule as `visitor_ip`: honored only under an HMAC\nsignature; otherwise the request's User-Agent header prevails.\n",
            "example": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"
          },
          "utm": {
            "type": "object",
            "additionalProperties": false,
            "description": "UTM parameters of the first visit (the page that hosts the tool).\nThey attach the lead to the campaign whose web source matches\n(per-campaign report, campaign tag), on first touch: a contact that is\nalready attributed never changes. Kept only for a submission that went\nthrough the tool (signed by its proxy or with a valid Turnstile token),\nnever for the contact of a personal link (`token`). An unreadable field\nis dropped, never rejected.\n",
            "properties": {
              "source": {
                "type": "string",
                "maxLength": 500,
                "example": "google"
              },
              "medium": {
                "type": "string",
                "maxLength": 500,
                "example": "cpc"
              },
              "campaign": {
                "type": "string",
                "maxLength": 500,
                "example": "pamphlet-automne"
              },
              "content": {
                "type": "string",
                "maxLength": 500
              },
              "term": {
                "type": "string",
                "maxLength": 500
              }
            }
          },
          "gclid": {
            "type": "string",
            "maxLength": 512,
            "description": "Google Ads click identifier of the first visit. Without `utm`, it\ncounts as a google / cpc source. Same rules as `utm`; dropped if\nunreadable. When `ads_consent` is `denied`, the identifier is never\nstored: only the google / cpc source remains.\n",
            "example": "Cj0KCQjw5ZSWBhCVARIsALERCvzZ"
          },
          "captured_at": {
            "type": "string",
            "format": "date-time",
            "description": "Time of the first visit. Dates the ad attribution; ignored if it is\nunreadable or in the future (the submission date then prevails).\n",
            "example": "2026-09-20T14:00:00.000Z"
          }
        }
      },
      "PresuasionSubmissionResponse": {
        "type": "object",
        "description": "Response of `POST /v1/presuasion-submissions` (200 OK).\n\n**Legacy convention** — custom body that does not follow the v1\n`{data: ...}` convention. Top-level `success` boolean plus scalar\nfields. `contact_created` indicates whether the contact was created\n(`true`) or only enriched (`false`).\n",
        "required": [
          "success",
          "contact_id",
          "contact_created",
          "activity_id"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "enum": [
              true
            ],
            "description": "Always `true` — a failure returns 4xx/5xx with an `{error: \"...\"}` body.",
            "example": true
          },
          "contact_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the created or found contact."
          },
          "contact_created": {
            "type": "boolean",
            "description": "`true` if a new contact was created, `false` if an existing contact\nwas enriched (missing fields were filled in, fields already present\nwere never overwritten).\n",
            "example": false
          },
          "activity_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the `presuasion_submission` activity created in the\ncontact's timeline.\n"
          }
        }
      },
      "PresuasionSubmissionError": {
        "type": "object",
        "description": "Error body of the 4xx/5xx responses of `POST /v1/presuasion-submissions`.\n\n**Legacy convention** — custom body that does not follow the v1\ncatalog's `ErrorEnvelope`. `error` is a human-readable string (not a\n`{code, message, ...}` object), and `issues` is present only for Zod\nvalidation errors (400 `Invalid payload`).\n",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message.",
            "example": "Invalid payload"
          },
          "issues": {
            "type": "array",
            "description": "Per-field details for Zod validation errors (400 only).",
            "items": {
              "type": "object",
              "required": [
                "path",
                "message"
              ],
              "properties": {
                "path": {
                  "type": "string",
                  "description": "Path of the offending field (dot notation).",
                  "example": "formatted_answers.0.question"
                },
                "message": {
                  "type": "string",
                  "description": "Zod message explaining the error.",
                  "example": "String must contain at least 1 character(s)"
                }
              }
            }
          }
        }
      }
    },
    "responses": {
      "InvalidApiKey": {
        "description": "Unknown API key or invalid format.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "authentication_error",
                "code": "invalid_api_key",
                "message": "La clé API fournie est invalide.",
                "hint": "Vérifie que tu utilises la valeur complète affichée à la création de la clé.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/authentication#invalid_api_key",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7"
              }
            }
          }
        }
      },
      "RateLimitExceeded": {
        "description": "The per-window request quota has been exceeded for this API key or endpoint.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/XRateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/XRateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/XRateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "rate_limit_error",
                "code": "rate_limit_exceeded",
                "message": "Quota de requêtes dépassé. Réessaie dans 42 secondes.",
                "hint": "Lis les headers `RateLimit-*` pour connaître la limite et le reset.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/rate-limits",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "limit_type": "per_key",
                  "retry_after_seconds": 42
                }
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error. Always include the `request_id` when contacting support.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "server_error",
                "code": "internal_error",
                "message": "Une erreur interne est survenue. L'incident a été reporté.",
                "hint": "Réessaie dans quelques secondes. Si l'erreur persiste, contacte le support avec le request_id.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#internal_error",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7"
              }
            }
          }
        }
      },
      "InvalidRequest": {
        "description": "The request is malformed (missing header, invalid JSON, invalid parameter).",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "validation_error",
                "code": "invalid_request",
                "message": "Le paramètre 'cursor' est invalide ou expiré.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#invalid_request",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "field": "cursor",
                  "reason": "malformed_base64"
                }
              }
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "A previous request with the same `Idempotency-Key` used a different payload,\nor is still being processed.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "idempotency_error",
                "code": "idempotency_conflict",
                "message": "Une requête avec cette Idempotency-Key existe avec un payload différent.",
                "hint": "Utilise une nouvelle Idempotency-Key, ou rejoue la requête originale à l'identique.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/conventions#idempotency",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "idempotency_key": "01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                  "original_request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T6"
                }
              }
            }
          }
        }
      },
      "InsufficientScope": {
        "description": "Authorization denied. Two causes, distinguished by `code`: `insufficient_scope` (the key lacks the required scope — `details.required_scope`) or `feature_not_available` (the public API or the targeted module isn't included in the account's plan — `details.feature` names the feature, e.g. `public_api`, `quotes`). A plan denial is final for the key: don't retry; the account must change plans.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "authorization_error",
                "code": "insufficient_scope",
                "message": "Cette opération requiert le scope `contacts:write`.",
                "hint": "Édite la clé pour lui ajouter le scope manquant, ou utilise une clé avec un scope plus large.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/authentication#scopes",
                "dashboard_url": "https://app.capturia.io/platform/dashboard/admin/integrations/api-keys/ak_01J9X8Z3F4K2M5N7P9Q1R3S5T7?tab=permissions",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "required_scope": "contacts:write",
                  "current_scopes": [
                    "contacts:read",
                    "leads:capture"
                  ]
                }
              }
            }
          }
        }
      },
      "ResourceNotFound": {
        "description": "The requested resource doesn't exist (or isn't within the tenant's scope).",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "not_found_error",
                "code": "resource_not_found",
                "message": "Le contact demandé n'existe pas.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#resource_not_found",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "resource": "contact",
                  "id": "01J9X8Z3F4K2M5N7P9Q1R3S5T7"
                }
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "Parameter or body validation failed.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "validation_error",
                "code": "validation_error",
                "message": "Le champ 'email' est requis.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#validation_error",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "validation_errors": [
                    {
                      "path": "/email",
                      "reason": "required"
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "InvalidContentType": {
        "description": "The `Content-Type` header is missing or not supported for this endpoint.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "validation_error",
                "code": "invalid_content_type",
                "message": "Content-Type `text/plain` non supporté. Utilise `application/json`.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/conventions#content-type",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "received": "text/plain",
                  "expected": [
                    "application/json"
                  ]
                }
              }
            }
          }
        }
      },
      "BusinessRuleViolation": {
        "description": "The request is syntactically valid but violates a business rule.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "business_rule_error",
                "code": "business_rule_violation",
                "message": "Impossible d'envoyer un devis déjà accepté.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#business_rule_violation",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "rule": "quote_already_accepted",
                  "current_state": "accepted"
                }
              }
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "Service temporarily unavailable (maintenance, external dependency down, degraded infrastructure).\nClients should retry with exponential backoff.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "server_error",
                "code": "service_unavailable",
                "message": "Service temporairement indisponible. Réessaie dans quelques secondes.",
                "hint": "Implémente un backoff exponentiel et respecte le header `Retry-After`.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#service_unavailable",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "retry_after_seconds": 30
                }
              }
            }
          }
        }
      },
      "DuplicateResource": {
        "description": "A resource with a conflicting unique identifier already exists.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "conflict_error",
                "code": "duplicate_resource",
                "message": "Un contact avec cet email existe déjà.",
                "hint": "Utilise PATCH /v1/contacts/{id} pour mettre à jour le contact existant.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#duplicate_resource",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "resource": "contact",
                  "conflicting_field": "email",
                  "conflicting_value": "prospect@example.com",
                  "existing_id": "01J9X8Z3F4K2M5N7P9Q1R3S5T8"
                }
              }
            }
          }
        }
      },
      "TagManagedByCapturia": {
        "description": "The targeted tag is a campaign tag managed by Capturia (a `system_key` that\nstarts with `campaign:`): it follows the lead's campaign and can't be attached\nor detached manually.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "conflict_error",
                "code": "tag_managed_by_capturia",
                "message": "Ce tag de campagne est géré par Capturia : il ne se pose ni ne se retire à la main.",
                "hint": "Capturia pose ce tag sur chaque lead de la campagne et le retire quand le lead change de campagne. Pour changer la campagne d'un lead, choisis-la dans sa fiche du tableau de bord.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#tag_managed_by_capturia",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "tag_ids": [
                    "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3"
                  ]
                }
              }
            }
          }
        }
      },
      "LeadDisqualified": {
        "description": "The targeted lead is disqualified: its stage can't change until the\ndisqualification is lifted. Moving it to a lost stage, or to\nthe disqualification stage, is still accepted.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "conflict_error",
                "code": "lead_disqualified",
                "message": "Ce lead est disqualifié : il doit être rouvert avant de changer d'étape.",
                "hint": "Rouvre le lead depuis sa fiche dans le tableau de bord, puis rejoue la requête. Une étape perdue ou une étape de disqualification reste acceptée sans réouverture.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/errors#lead_disqualified",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                  "stage_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
                }
              }
            }
          }
        }
      }
    },
    "parameters": {
      "IdempotencyKeyHeader": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255,
          "pattern": "^[A-Za-z0-9_\\-]+$"
        },
        "description": "Idempotency key (UUID v4 or ULID recommended). A second request with the same key and the same payload\nreturns the original response without repeating the operation. Stored for 24h. Optional but recommended.\n"
      },
      "CursorParam": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "schema": {
          "$ref": "#/components/schemas/Cursor"
        },
        "description": "Opaque pagination cursor — pass the `pagination.next_cursor` value from the previous page."
      },
      "LimitParam": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 20
        },
        "description": "Maximum number of results per page (default 20, max 100). Above that → 400 invalid_request."
      },
      "CreatedAfterParam": {
        "name": "created_after",
        "in": "query",
        "required": false,
        "schema": {
          "$ref": "#/components/schemas/Timestamp"
        },
        "description": "Filter — only resources created after this date (inclusive)."
      },
      "CreatedBeforeParam": {
        "name": "created_before",
        "in": "query",
        "required": false,
        "schema": {
          "$ref": "#/components/schemas/Timestamp"
        },
        "description": "Filter — only resources created before this date (inclusive)."
      },
      "ResourceIdParam": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "UUID of the resource."
      }
    }
  }
}
