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,
    custom code, external CRMs) through REST endpoints authenticated with an API key.

    ## Quickstart

    1. Create an API key in the dashboard: `Admin → Integrations → API Keys`
    2. Pick the matching preset (`Lead capture` to push leads from a funnel)
    3. Call `GET /v1/me` to confirm the key works
    4. Capture a lead: `POST /v1/leads/capture`

    Full documentation: https://capturia.io/en/developers
  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
      for one of the account's events (webinar, challenge, summit, online
      event)
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
        (name, scopes, inferred preset, expiration, rate limits, client). It's the simplest
        way to confirm that a third-party integration is connected. No mutations, no side
        effects — safe to call from any smoke test script.

        No specific scope is required; any authenticated, active key can call this
        endpoint. Each call consumes 1 unit of the per-key rate limit (so a script stuck in
        a loop can't flood the tracking).
      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
        usage over a given time range, plus totals computed application-side. Use it to
        size your usage, monitor latencies, and spot rate limit rejections.

        No specific scope is required (same pattern as GitHub's `/rate_limit`): reading
        your own metrics is free and always allowed for any authenticated key.

        The range `(to - from)` must be strictly positive and must not exceed
        **90 days**. Returned buckets are sorted by `bucket_start ASC`; windows with no
        traffic are omitted from the array (no zero-padding).
      security:
        - ApiKeyAuth: []
      parameters:
        - name: from
          in: query
          required: true
          description: |
            Lower bound (inclusive) of the time range, ISO 8601 UTC.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: to
          in: query
          required: true
          description: |
            Upper bound (exclusive) of the time range, ISO 8601 UTC. Must be
            strictly after `from`, and the range `(to - from)` must be ≤ 90 days.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: granularity
          in: query
          required: false
          description: |
            Granularity of the returned buckets. Defaults to `day`.
          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
        (Make, Zapier, n8n, custom landing page). Once captured, the lead shows up
        immediately in **My Prospects** in the dashboard, and the SMS bot takes
        over within 30 seconds if consent and configuration are in
        order.

        ### Contact already known (email or phone)
        The contact is resolved by **email first** (case-insensitive),
        then by **phone**, including secondary contact details already
        attached to a contact. Capturing a lead whose email or phone
        already exists **reuses and enriches the existing contact** — the
        returned `contact_id` is that contact's, never a duplicate and
        never an error. Contact details and core fields that are already filled
        in (name, email, phone, pipeline, value, address...) are not
        overwritten; qualification answers (`custom_fields`), on the other
        hand, are refreshed with the latest values sent — a key that is
        already present is replaced, a new key is added. A new
        phone number is kept as a secondary number when the contact already
        has one. If the email and the phone point to two different
        contacts, the email takes priority and no contact detail is removed
        from the other contact.

        The response exposes `contact_was_new` to tell the two outcomes apart:
        on an existing contact (`false`), `pipeline` and `assignment_status`
        describe the resolved request, not necessarily the persisted state — the
        contact keeps the pipeline, stage, and sales rep it already has, and
        `ignored_fields` names the fields of the `pipeline` block it did
        not take (`existing_contact_kept`).

        ### Received fields not applied (`ignored_fields`)
        A capture that responds 200 tells you what it did not apply. The response
        carries `ignored_fields`, a `[{ field, reason }]` list that is always
        present and empty when everything was applied:

        - `unknown_field` — a name the API does not know, at the root, in
          `customData`, or in a block (`pipeline.id`, `utm.utm_source`);
        - `custom_field_not_defined` — a `custom_fields` key with no
          definition of the same name in the account;
        - `reserved_tag` — a tag with a reserved name (`tags[1]`);
        - `system_not_active` — a `system_id` that is not active;
        - `existing_contact_kept` — a field of the `pipeline` block that the
          existing contact kept;
        - `sales_rep_not_found` — a sales rep email that matches no
          active member of the account;
        - `submission_not_recorded` — what only applies with a
          recorded submission, when none is recorded
          (`existing_pipeline_contact`, `idempotent_replay`);
        - `duplicate_field` — a copy of a field received from several sources,
          set aside in favor of another value.

        Only limit: on an existing contact, identity and address fields that
        are already filled in, and ad attribution that is already set
        (first-touch rule), are kept without being named one by one
        (`contact_was_new: false` signals it).

        The lead is captured in all of these cases: this is a setup warning
        to read on the first test of an integration. The list is
        capped at 50 entries (`ignored_fields_truncated`), with fields you mapped
        by hand listed before the ones a connector adds automatically.

        ### Pipeline and stage
        The `pipeline` block designates the pipeline and the stage by their text
        identifier (`pipeline_slug`, `stage_slug`), which you copy from the dashboard:
        Administration, API Keys, "Your pipeline and stage identifiers"
        section. A connector that only sends key/value pairs
        (GoHighLevel workflow, Zapier) sends them flat:
        `pipeline.pipeline_slug`, `pipeline.stage_slug`. Without
        `pipeline_slug`, the stage is looked up in the default pipeline.

        ### Contact already in an open pipeline
        A lead whose contact (resolved by email or phone) is already in
        an open pipeline stage (already handled by a sales rep — not
        archived, stage neither won nor lost)
        is not resubmitted: the response returns `status: existing_pipeline_contact`,
        no new submission is recorded, and the SMS bot is not
        triggered again, so an ongoing conversation is not overwritten. Newly
        provided core fields still enrich the existing contact.

        Retry deduplication takes precedence: a re-push received within the
        5-minute window is treated as a retry (`status: idempotent_replay`, see
        Idempotency) and keeps the original `activity_id` and
        `original_created_at` — `existing_pipeline_contact` only applies after this
        window.

        ### Idempotency
        The `Idempotency-Key` header is **optional but recommended** for client-side
        retries. A second request with the same key and the same payload returns the
        original response without duplicating the lead. The key is stored for 24h. A second
        request with the same key but a different payload returns 409
        `idempotency_conflict`. The underlying RPC also keeps its own
        5-minute dedup window. This window is **per contact and
        independent of content**: any submission for a contact already captured
        within the last 5 minutes is treated as a retry
        (`idempotent_replay`) even if the payload differs — the contact is still
        enriched (including `custom_fields`, which are refreshed), but no
        new activity, no tag, and no SMS bot follow-up. Updates to the
        contact remain real: an automation that watches a
        custom field reacts to the value change, as it does for any
        change to the contact. This is also what prevents a double
        bot follow-up when a lead resubmits their form twice in a row.
        A different `Idempotency-Key` does not bypass this window: it
        applies in the database after any cache miss. Two intentionally
        distinct submissions from the same contact only produce two activities
        if they are more than 5 minutes apart.

        ### Custom error codes
        For historical reasons, some rejections return codes outside the
        standard catalog:

        - **422** — `invalid_phone`, `missing_consent`, `invalid_email`,
          `invalid_payload`, `pipeline_not_found`, `stage_not_found` (instead of
          `validation_error` / `business_rule_violation`). Simplified body shape
          specific to this route — see the 422 response below.
        - **429** — `rate_limited_ip` when the IP limit (100 req/min) is reached
          (instead of `rate_limit_exceeded`). The `X-RateLimit-Scope: ip` header
          identifies the scope of the triggered limit. Simplified body shape (no
          standard `request_id` or `details`) — to be aligned in a later
          phase.
      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
            capture (`status: idempotent_replay`), or contact already
            handled (`status: existing_pipeline_contact` — already in an open
            pipeline stage: no new submission is recorded, the
            SMS bot is not triggered, and `activity_id` is `null`). In all
            cases, the returned `contact_id` is stable.
          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
            an organization that has not yet accepted the API terms of
            use (`terms_acceptance_required`). Standard error
            envelope.
          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`,
            `invalid_email`, `invalid_payload`) or by pipeline resolution
            (`pipeline_not_found`, `stage_not_found`). Simplified shape specific to
            this route: no `type` or `doc_url`, and `details` is an
            array `[{ field, reason }]` (`field` is empty when the body is not
            an object). Only `system_not_found` (a `system_id` that matches
            no System in the account) comes back in the standard error
            envelope.
            For an invalid scalar field, the `message` echoes back the
            received value (capped at 120 characters) — it is never stored
            by Capturia; only the names of the received fields are, visible
            in the API key's Recent errors tab. The `request_id` is
            always in the `X-Request-ID` header.
          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,
                              prefixed with `Received <value> for field "<field>" —`.
                          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`,
                                    `pipeline.stage_slug`, `custom_fields.ma_cle`).
                                reason:
                                  type: string
                  - type: object
                    description: |
                      Standard error envelope, for `system_not_found`:
                      `system_id` matches no System in the account, and no
                      contact is created.
                    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,
        n8n, a client backend, a third-party tool) pushes a named event with
        its data. Capturia resolves the contact (`external_id` → `phone` →
        `email`), enriches the contact non-destructively, fills in the mapped custom
        fields, records the event on the contact, then starts
        the automations whose "Custom event" trigger
        listens for this event name — with the `payload` available in
        messages through `{{event.<key>}}`.

        ### Contact resolution
        At least one identifier is required (`external_id`, `email` or `phone`).
        If no contact matches, a minimal contact is created — unless
        `contact.create_if_missing` is `false`, in which case the request returns
        `404 contact_not_found` without recording anything. If the identifiers
        only match an **archived** contact, the event is recorded on it
        (for traceability) but the contact is not modified and no automation
        is enrolled (`contact_archived: true`).

        ### Consent (Law 25 / CASL)
        Receiving an event does **not** establish any communication
        consent. A contact created through this path cannot receive
        SMS/email until consent is captured through another flow;
        a triggered automation that tries to send without consent
        is blocked by the execution engine's guards.

        ### Optional HMAC signature
        In addition to the API key, the request can carry a signature
        `X-Capturia-Signature: t=<unix>,v1=<hex>` — an HMAC-SHA256 of
        `${t}.${raw body}` computed with **the raw API key as the secret**,
        with a 5-minute anti-replay window. As soon as the header is present,
        validation is strict (fail-closed): an invalid signature = `401
        invalid_signature`. Without the header, the API key alone authenticates the request.

        ### Idempotency
        The `Idempotency-Key` header is optional but recommended for
        retries: a second request with the same key and the same payload returns the
        original response without ingesting the event again (stored for 24h; a
        different payload = `409 idempotency_conflict`). In addition, automation
        enrollment has its own 5-minute deduplication per contact and per
        automation.

        ### Custom error codes
        - **429** — `rate_limited_ip` when the IP rate limit (120 req/min) is
          reached, with header `X-RateLimit-Scope: ip`. Per-key limit: 60 req/min
          (adjustable per key).
      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
            how many automations started; `0` means no
            active automation listens for this event name — the event is
            still recorded on the contact.
          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
        (webinar, challenge, summit, online event). The person then receives
        the event's messages, just as if they had registered
        through its page.

        One registration per person and per edition: a second call
        for the same contact and the same edition returns the same registration with
        `200` and `created: false`, without resending the confirmation. It updates the
        language, and re-registers a person who had said they would not
        attend; a person who asked to stop receiving messages stays
        opted out. The call is therefore safe to retry, without an
        `Idempotency-Key` header.

        Requires the plan's Event support module
        (`403 feature_not_available` otherwise) and the `contacts:write` scope.

        ### Rejections

        - `404 resource_not_found` with `details.resource`: `contact`
          (contact missing, merged or from another account), `event` or
          `edition`.
        - `422 business_rule_violation` with `details.rule`:
          `event_not_published` (draft), `event_closed` (event
          archived, edition cancelled, or no upcoming edition),
          `event_edition_ended` (edition ended).
        - `400 validation_error`: invalid body, or `event_id` and
          `event_slug` both present or both missing.
      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).
        Stable sort `created_at DESC, id DESC`. Supports case-insensitive
        search on `email`, `first_name`, `last_name`, `company`, filtering
        by tag, and optional inclusion of the custom field definitions in
        `meta`.

        ### Merged contacts

        A contact absorbed by a merge no longer appears in the list: its
        history lives on the surviving contact, which is the only one listed.

        ### Search

        The `?search` parameter is split into words (separated by spaces).
        Each word must appear, case-insensitively, in at least one of the
        `email`, `first_name`, `last_name`, or `company` columns, and the
        words are combined with AND, in any order:
        `?search=Jean Bouchard` finds the contact whose first name is "Jean"
        and whose last name is "Bouchard". Each word is sent as a quoted
        literal value: commas, parentheses, and periods don't alter the
        filter.

        ### Including custom fields

        Pass `?include=custom_field_definitions` to add the
        `meta.custom_field_definitions` field to the response — useful for
        rendering an edit form on your side without a second round-trip.
      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,
            paginated like the full list. To
            count a campaign's leads, pass the `id` of its
            "Campagne : <name>" tag (`system_key` starting with `campaign:`
            in `GET /v1/tags`) and walk the pages (`next_cursor`)
            until `has_more: false`: `pagination.total_count` is only an
            estimate. A value that isn't a UUID is
            rejected (`400 invalid_request`, `details.field = "tag"`).
        - 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
        dashboard. An **email or a phone number** is required
        (either one is enough). The email is stored in lowercase; the
        phone number is normalized to E.164 when it's a valid North American
        number.

        The person is looked up across all of the account's identities
        (primary and secondary emails and phone numbers, case-insensitive).
        If they already exist, nothing is created: the response is
        `409 duplicate_contact`, with the existing contact in `data` and the
        matching fields in `details.conflicting_fields`. If the email and
        the phone number belong to two different contacts, the response is
        `409 duplicate_resource`, with no contact.

        ### Source and consent

        The created contact has the source `api`. An integration doesn't name
        anyone who actually met this contact: **no implied
        consent** (SMS or email) is recorded when it's created. Capturia's SMS
        follow-ups and prospecting emails are only sent to it
        once a real basis for consent exists
        (form, booking, inbound message, attestation in
        Capturia). To capture a lead who just filled out your form,
        with their consent, use `POST /v1/leads/capture`.

        ### Idempotency

        The `Idempotency-Key` header is **optional but recommended**.
        A second request with the same key and the same payload returns the
        original response without repeating the operation. A second request with
        the same key and a different payload returns `409 idempotency_conflict`.

        ### Custom fields

        The keys of `custom_fields` are the field's **name** as defined
        in Capturia (or, equivalently, the id of its definition) —
        the same contract as `/v1/events` and `/v1/leads/capture`. The values
        are validated against the account's definitions
        (`client_custom_field_definitions`). An unknown field or an
        incompatible type returns `400 invalid_custom_fields` with `details`
        listing the violations.
      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
        `client_contact_tags` table to include the attached tags (id, name,
        color, system_key) without an extra round-trip.

        The ID of a contact absorbed by a merge returns `404`: reads, updates,
        and deletes go through the surviving contact. If the merge is undone
        from the dashboard, the contact becomes reachable again under its ID.
      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.
                      `ContactWithTags` extends `Contact` via `allOf` — a payload
                      without tags satisfies both schemas (hence `anyOf`).
              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).
        You must provide at least one field, otherwise `400 no_fields`.

        The `custom_fields` field is **merged** with the existing values
        (keys you don't provide are kept). The keys are the field's **name**
        (or the id of its definition); an unknown key returns
        `400 invalid_custom_fields`. Passing `null` clears all custom
        fields.

        Phone number and email are unique per account: a value already
        held by another contact (as a primary or secondary
        contact detail) returns `409 duplicate_resource`
        (`details.conflicting_field` names the field) and the contact isn't
        modified. The phone number is compared once normalized to E.164.

        The update goes through the same service as the contact in the
        dashboard: same validations (email, phone number, positive amounts,
        each custom field's value according to its type), same entry in
        the account's timeline and activity log, and the same
        `lead_updated` webhook. An invalid value returns
        `400 validation_error` (`details[].field` names the field); a
        phone number that can't be dialed (that doesn't normalize to
        E.164) is rejected with `details[].reason` = `invalid_phone`.

        A contact always keeps at least a first name, a last name, an email, or
        a phone number: a request that would clear the last one returns
        `400 validation_error` (`details[].reason` = `identity_required`), and
        the contact isn't modified.

        The value (`deal_value`) of an archived contact can't be changed:
        `422 business_rule_violation` (`details.reason` =
        `contact_archived`), and no field from the request is written.
        Restore the contact from the dashboard first.

        The `notes` field is the contact's free-form text, replaced on every
        write. To add a note to the contact's timeline, use
        `POST /v1/contacts/{id}/notes`.
      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
        dashboard: its inbox conversation, its exchanges (SMS, emails,
        chat transcripts), its bookings, and its files go with
        it. Its voicemails and media received by SMS go too, as
        do the audio of its calls and meetings and the word-for-word
        transcripts of its calls, including at our telephony
        provider: this removal is durable and retried automatically if the
        provider is temporarily unreachable. Its call log
        (summary, notes, outcome) remains, detached from the contact, as do
        sales, orders, and payments.

        Removal outside the platform (bookings in the connected Google calendar,
        stored files) happens right after, on a best-effort basis: the
        contact's deletion from the database succeeds even if an external service is
        temporarily unreachable. The response's `cleanup` object reports it —
        `failed` > 0 means some Google bookings couldn't be
        removed from the calendar (account disconnected, outage) and remain there.

        Requires the `contacts:delete` scope, separate from writing: as in
        the dashboard, deleting a contact is a separate permission.
        Only someone who can delete leads in Capturia
        can grant it to an API key.
      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,
        SMS/email sends, bookings, etc.) in reverse chronological order.

        ### Current limitation

        The implementation is a stub: the `pipeline_history` table references
        the other, internal `contacts` table (not `client_contacts`), so
        the returned list is **always empty** today. Pagination
        is validated but not applied. To be implemented in a later
        phase once the history is unified on `client_contacts`.
      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
        `client_contact_tags` join table. The tags must belong to the same account
        as the contact — otherwise `400 validation_error` with the list of
        tags not found. An identifier that isn't a UUID also returns
        `400 validation_error`.

        The operation is idempotent at the database level: calling it twice with the
        same `tag_ids` doesn't create duplicates (upsert
        `ON CONFLICT (contact_id, tag_id) DO NOTHING`).

        ### Campaign tags

        A tag whose `system_key` starts with `campaign:` is managed by
        Capturia: it follows the lead's campaign. If the contact already carries it, it
        is ignored (sending back a lead's list of tags stays idempotent).
        If the contact doesn't carry it, it returns `409 tag_managed_by_capturia` and
        none of the request's tags are attached.

        ### Listing a contact's tags

        There's no dedicated `GET` — use
        `GET /v1/contacts/{id}?expand=tags` to retrieve the tags
        currently attached.
      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:…`
                                    for a campaign's tag), `null` for an account tag.
              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
        isn't deleted — it remains available for other contacts.

        If the tag isn't attached to the contact (or if the contact belongs
        to another account), returns `404 resource_not_found`.

        A campaign tag (`system_key` starting with `campaign:`) follows
        the lead's campaign: detaching it returns
        `409 tag_managed_by_capturia`.
      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
        the contact in the dashboard: it's added to the previous notes,
        appears in the contact's Notes tab, and feeds the lead's
        qualification. The note is signed by the API key that wrote it.

        Not to be confused with the contact's free-form text field `notes`
        (`PATCH /v1/contacts/{id}`), replaced on every write.

        The content is required and is at most 20,000 characters; otherwise
        `400 validation_error`. A contact that's unknown, belongs to another account, or
        was merged into another contact returns `404`.

        ### Idempotency

        The `Idempotency-Key` header is **optional but recommended**: a
        second request with the same key, the same contact, and the same content
        returns the original note without writing a second one. The same key
        with another contact or other content returns
        `409 idempotency_conflict`.
      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.
        Contacts whose email already exists under the account are **updated**
        (the other fields you provide replace the existing values), the
        rest are **inserted**.

        ### Internal deduplication

        The payload is deduplicated internally by `lower(email)` (last entry
        wins) before the upsert. The order of the contacts in the array and the
        case of the email don't affect the idempotency hash.

        ### Idempotency

        The `Idempotency-Key` header is **optional but recommended**. The
        idempotency hash covers the payload **after internal deduplication** —
        a network retry stays idempotent even if you resend the contacts in
        a different order or with different email casing.

        ### Response

        The endpoint returns `200` with `{data: {processed, contacts: []}}`
        — no per-contact multi-status. A validation error on a
        single contact rejects the whole batch (`400 validation_error`
        with `details` listing each faulty row). A database error
        during the upsert returns `500` — contacts already persisted
        before the error remain (no transactional rollback
        across statements).

        When `system_id` is provided and resolves to an active System, the
        response also includes `system_routing`: the summary of routing the
        **created** contacts to that System. Routing is best-effort
        (the upsert is already committed when it runs) — `refused + errors` =
        created contacts that stayed in the main System.
      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
                    contacts **created** by this batch are engaged in this
                    System; updated contacts keep their engagement.
                    Omitted → main System (historical behavior). UUID
                    unknown for this account → `system_not_found` (422), no
                    contact written; archived System → silent fallback to the
                    main System.
                  example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
                trigger_automations:
                  type: boolean
                  default: false
                  description: |
                    Triggers the account's active automations and sequences
                    for the contacts in this batch (contact created, tags
                    added). **Default `false`: nothing is triggered** —
                    no automation or sequence enrollment, no
                    email or SMS caused by this import. When `true`, the response
                    returns `automations_enrolled` and, if the engine's hourly
                    cap (200 enrollments/account/hour) is reached,
                    `automations_skipped_rate_limit` — never a silent
                    drop.
            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.
                          Always `0` when `trigger_automations` is omitted
                          or `false`.
                      automations_skipped_rate_limit:
                        type: integer
                        minimum: 0
                        description: |
                          Enrollments refused because the automation engine's hourly
                          cap (200 enrollments/account/
                          hour) was reached. `> 0` = part of the batch didn't
                          enter the automations — re-import these
                          contacts later or enroll them manually.
                      sequences_enrolled:
                        type: integer
                        minimum: 0
                        description: |
                          Email sequence enrollments created by this batch.
                          Always `0` when `trigger_automations` is omitted
                          or `false`.
                      contacts:
                        type: array
                        description: |
                          Persisted contact rows: main fields and
                          location (same fields as `Contact`), without
                          `custom_fields`, `source`, or `pipeline_stage_id`.
                        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
                          target System. Present only when `system_id` was
                          provided and resolved to an active System.
                          Routing is best-effort: `refused + errors` =
                          created contacts that stayed in the main System.
                        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)
        (cursor v2). Stable sort: `created_at DESC, id DESC`.

        A conversation groups a visitor's messages with an agent
        (AI or human) under the calling account. Threads are created and
        messages are sent from the front end / chat SDK — the public
        API is read-only.

        ### Filters

        - `?agent_slug` — exact match on the AI agent replying in the thread.
        - `?agent_identity_id` — exact match on the thread's specific AI agent
          (an account can have several chatbots).
        - `?source` — exact match on the thread source (URL, funnel
          label, `widget`, etc.).
      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
        retrieve the individual messages, use
        `GET /v1/conversations/{id}/messages`.

        If the conversation belongs to another account (or doesn't exist),
        returns `404 resource_not_found` — tenant isolation hides
        the existence of conversations outside the calling account.
      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
        **chronological reading order** (`created_at ASC, id ASC`),
        with an `id` tiebreaker to prevent duplicates or skipped rows between
        two pages when several messages share the same millisecond.

        This sort direction (ASC) is the reverse of the other
        v2 list endpoints (DESC) — a conversation reads naturally
        in the chronological order of the exchange.

        Each message tells you who wrote it (`author_kind`): the visitor,
        the AI agent, or a sales rep on the team who replied from the Inbox
        (`author_name` then holds their first name).

        If the conversation belongs to another account (or doesn't exist),
        returns `404 resource_not_found`.
      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
        the key's account: all its calendars, all its hosts. Bookings
        archived from the dashboard are excluded. Stable sort
        `start_datetime DESC, id DESC` — the furthest-out bookings first,
        then the history.

        ### Filters

        - `?from` (ISO 8601) — `start_datetime >= from`
        - `?to` (ISO 8601) — `start_datetime <= to`
        - `?status` — `confirmed`, `cancelled`, `completed`, or `no_show`
        - `?event_type_id` — a calendar of the account
        - `?contact_id` — a contact of the account

        An unreadable filter is rejected with `400 validation_error`, with the
        field and the reason (`invalid_datetime`, `invalid_value`,
        `invalid_format`); an invalid cursor or limit, `400 invalid_request`.
        An `event_type_id` or a `contact_id` from another account returns an
        empty list.
      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
        calendars, through the same service as the booking page and the
        dashboard. The calendar's rules apply: its active hosts, their hours
        and their Google Calendar, the booking window, the slot cadence, the
        daily maximum, group sessions, required questions, the access rule,
        and a single upcoming booking per contact on this calendar. The
        requested time is honored or rejected with its reason — never
        replaced with another. The duration is the calendar's; the host
        receives their notification and the event is added to their calendar
        as for any booking.

        Each successful call creates a booking: don't replay a call that
        succeeded.

        ### Rejections

        The `details.reason` field states why, in a stable way:

        | Status | `code` | `details.reason` |
        |---|---|---|
        | 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) |
        | 404 | `resource_not_found` | — (`details.field`: `event_type_id`, `contact_id`, or `host_id`) |
        | 409 | `duplicate_resource` | `already_booked` (`details.existing_booking_id`) |
        | 422 | `business_rule_violation` | `calendar_without_hosts`, `host_not_on_calendar`, `host_unavailable` (`details.causes`), `slot_unavailable`, `contact_archived`, `lead_not_qualified` |
        | 503 | `service_unavailable` | — (retry after `Retry-After`) |

        A field format error (`400 validation_error`) carries `details` as a
        list `[{ field, reason }]`.
      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
            (`duplicate_resource`, reason `already_booked`, with
            `details.existing_booking_id`).
          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
            active host (`calendar_without_hosts`), host who isn't a host of this
            calendar (`host_not_on_calendar`), host unavailable
            (`host_unavailable`, causes in `details.causes`:
            `outside_window`, `outside_hours`, `other_booking`,
            `blocked_time`, `google_busy`, `daily_max`), slot taken or
            full (`slot_unavailable`), archived contact (`contact_archived`),
            lead excluded by the calendar's access rule or disqualified
            (`lead_not_qualified`).
          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
        to `cancelled` and `cancelled_at` is timestamped, like a
        cancellation made in the dashboard. The event is removed from the
        host's calendar, the host is notified, and the cancellation is
        recorded as made by one of your team's integrations. The `200`
        response returns these 3 fields (not `204 No Content`).

        Only a `confirmed` booking can be cancelled. A booking that is
        already cancelled returns `422 business_rule_violation` (reason
        `already_cancelled`); a booking that was held or marked as a no-show,
        `422` (reason `booking_not_cancellable`). A booking from another
        account is not found (`404`).

        To change the time, cancel and book again, or reschedule from the
        dashboard — there is no `PATCH`.
      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
            held or marked as a no-show (`booking_not_cancellable`).
          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
        `created_at DESC, id DESC`.

        Archived quotes (`archived_at IS NOT NULL`) are excluded by
        default — the public API does not expose them. To retrieve an archived
        quote, use the dashboard.

        ### Filters

        - `?status` — exact match (`draft`, `sent`, `signed`, `cancelled`)
      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
        path as the dashboard: Capturia assigns the number
        (`quote_number`, the account's sequence, never a duplicate) and applies the
        account's defaults (validity, payment terms, tax profile,
        deposit policy). A provided `quote_number` is rejected
        (`400 validation_error`, reason `read_only`).

        `contact_id` links the quote to its contact (`/v1/contacts`): each
        omitted `prospect_*` field is read from the contact, and each field you provide
        takes precedence. `line_items` writes the line items in the same operation; the
        server computes the amounts. To send the quote to the
        lead, use `POST /v1/quotes/{id}/send`.

        ### Idempotency

        The `Idempotency-Key` header is **optional but recommended**: a
        retry after a timeout never creates a second quote. A
        second request with the same key and the same payload returns the original
        response; the same key with a different payload returns
        `409 idempotency_conflict`. A rejection (400, 403, 404) is not
        stored: the key remains usable.
      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).
        At least one field must be provided, otherwise `400 no_fields`.

        Amounts (`subtotal`, `tps_amount`, `tvq_amount`, `total`) are
        not editable via PATCH — they are computed server-side from the
        blocks (managed from the dashboard).

        `status` only accepts `declined` (the lead declined it) and
        `cancelled`: a quote moves to `sent` through `POST /v1/quotes/{id}/send`
        (the email goes out), to `viewed` and `signed` through the lead, and to
        `expired` through its validity date. A signed, paid, or already closed quote
        can no longer be closed (`409 status_locked`). Closing must be done on its own:
        `status` combined with other fields returns `400 validation_error`
        (reason `exclusive_field`).

        Archived quotes (`archived_at IS NOT NULL`) cannot be
        modified — returns `404 resource_not_found`.
      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
        dashboard's “Send” button: sent from the assigned sales rep (otherwise
        from the business), with a link to the quote's public page, then
        `status="sent"` and `sent_at` are set. Only a draft can be sent; nothing
        goes out again for a quote that was already sent.

        A quote without a contact (lead entered without a contact) is
        first linked to the lead's contact: found by their email or
        phone number, otherwise created — never a duplicate. The email then opens
        its thread in the Inbox. The created contact stays in the API key's account,
        and its creation triggers the automations and CRM sync
        of a new contact.

        ### Rejections

        - `409 already_signed`: the quote is signed.
        - `409 quote_cancelled`: the quote was cancelled.
        - `409 already_sent`: the quote was already sent.
        - `409 quote_status_changed`: the quote is no longer a draft (for example, it expired).
        - `409 send_in_progress`: a send of this quote is already in progress.
        - `409 send_outcome_unknown`: a previous send could not be confirmed; it is not retried.
        - `409 recipient_unsubscribed`: the lead unsubscribed.
        - `409 sender_unavailable`: the assigned sales rep has no valid sending address.
        - `422 no_prospect_email` / `422 invalid_prospect_email`: the lead's email is missing or invalid.
        - `422 unpriced_lines`, `422 untaxed_quote`, `422 deposit_not_payable`: content guard.

        No body is required.
      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
        `name ASC, id ASC` — alphabetical order makes it easy to build
        a selection UI. The `id ASC` tiebreaker guarantees a total order
        across pages (names are unique within the account and case-sensitive).

        ### Cursor sanitization

        Since `name` is user-controlled, the cursor encodes values with
        PostgREST escaping on the server — no action is required on your side.
      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
        required — `color` defaults to `#6b7280` (neutral
        gray) if not provided. To attach the tag to an existing contact,
        use `POST /v1/contacts/{id}/tags` with the identifier returned
        by this endpoint.

        The name is unique within the account: a name that is already taken returns
        `409 duplicate_resource`. A name that starts with "Campagne :"
        (any casing and spacing) is reserved for campaign tags managed by
        Capturia and returns `400 validation_error`, with
        `details: { field: "name", reason: "reserved_prefix" }`. A missing
        `name` returns `400 validation_error` with
        `details: { field: "name", reason: "required" }`.
      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).
        Stable sort `created_at DESC, id DESC`. Archived automations
        (`archived_at IS NOT NULL`) are excluded — the public API does not
        expose them.

        ### Filters

        - `?status=active` — only automations with `is_active=true`.
        - `?status=inactive` — only automations with `is_active=false`.

        ### Read-only

        The public API only exposes read access (list + history) and
        manual triggering. Creating, editing and archiving an automation
        happen in the dashboard — there is no POST/PATCH/DELETE
        `/v1/automations` endpoint.
      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"`,
        claimable by the automation engine (asynchronous, no guarantee of
        synchronous completion). To track the execution's progress,
        call `GET /v1/automations/{id}/history`.

        ### Preconditions

        - Automations must not have been removed from the account —
          otherwise `403 feature_not_available`.
        - The automation must belong to the calling client and must not be
          archived — otherwise `404 not_found`.
        - The automation must have `is_active=true` — otherwise
          `409 automation_inactive`.
        - The automation must be published — otherwise `409 automation_not_published`.
        - The published automation must have a trigger node — otherwise
          `409 no_trigger`.
        - The `contact_id` must belong to the calling client — otherwise
          `404 not_found`. A contact absorbed by a merge also returns
          `404 not_found`: trigger on the contact that was kept.

        ### Enrollment guards

        Manual triggering goes through the same guards as automatic
        triggers:

        - Contact already in an active run of this automation — otherwise
          `409 already_enrolled` (turn on Multiple concurrent runs in the
          Enrollment settings to allow concurrent runs).
        - Duplicate within the last 5 minutes, cap of 5 enrollments per
          contact per 24 h on this automation, or global cap of 200
          executions per hour for the account — otherwise `429 trigger_throttled`.

        The body accepts an optional `event_data` (free-form JSON object) injected into
        the execution and available to the graph's template variables.
      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).
        Stable sort `created_at DESC, id DESC` — most recent executions
        first. An execution is one run of the automation for a given
        contact; the engine can restart it after a failure
        (`retry_count > 0`).

        The automation must belong to the calling client and must not be
        archived — otherwise `404 not_found`.
      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).
        Stable sort `created_at DESC, id DESC`.

        A deal is NOT a separate entity in the database: it is a
        `client_contacts` row with `pipeline_stage_id IS NOT NULL`. The set
        of exposed fields is intentionally limited to what matters for
        the pipeline view (contact details + stage + value + score). For the full
        contact (custom_fields, tags, notes), use `GET /v1/contacts/{id}`.

        ### Filters

        - `?stage_id` (uuid) — exact match on the pipeline stage.

        ### Merged contacts

        A contact absorbed by a merge no longer appears in the list: its
        history lives in the surviving contact, which is the only one listed.

        ### No single GET

        No `GET /v1/pipeline/deals/{id}` endpoint is exposed — to
        retrieve an individual deal, use `GET /v1/contacts/{id}` or
        filter this list by `?stage_id`.
      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
        `pipeline_stage_id`). **Does not create a contact** — `contact_id` must
        already exist and belong to the calling account (otherwise `404 not_found`).
        The same applies to `stage_id` (`404 not_found` if the stage does not belong
        to the account).

        A `contact_id` that points to a contact absorbed by a merge returns
        `404 not_found`: place the surviving contact instead. If the merge is
        undone from the dashboard, the contact becomes accessible again under
        its ID.

        If the contact is already in a stage of the same pipeline, it is
        moved there. The move goes through the same engine as the
        dashboard: it appears in the contact's timeline, triggers the
        stage automations and the webhook, and counts as a human
        decision (the AI agent stops following up with this lead).

        A contact filed in a different pipeline than the target stage's
        is not moved: `422 business_rule_violation` with
        `details.reason` = `other_pipeline` (moving a contact between pipelines
        is done from the dashboard). An archived contact is not moved
        either (`details.reason` = `contact_archived`).

        A **disqualified** lead does not change stage until its
        disqualification is lifted: the request returns
        `409 lead_disqualified`. Moving it to a lost stage, or to the
        disqualification stage, is still accepted. You reopen the lead from
        its contact in the dashboard.
      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
        stage move is exposed through this endpoint — to change the
        deal's other fields (email, deal_value, etc.), use
        `PATCH /v1/contacts/{id}`.

        ### Preconditions

        - The target deal must exist, belong to the calling account, and have
          `pipeline_stage_id IS NOT NULL`, otherwise `404 not_found`. The ID of a
          contact absorbed by a merge also returns `404 not_found`: move
          the surviving contact.
        - The target stage must belong to the calling account, otherwise
          `404 not_found`.
        - The lead must not be disqualified, otherwise
          `409 lead_disqualified`. A lost stage or the disqualification
          stage is still an accepted target; otherwise, reopen the lead
          from its contact in the dashboard before retrying the request.
        - The target stage must belong to the deal's pipeline, and the contact
          must not be archived, otherwise `422 business_rule_violation`
          (`details.reason` = `other_pipeline` or `contact_archived`).

        The move goes through the same engine as the dashboard: it
        appears in the contact's timeline, triggers the stage
        automations and the webhook, and counts as a human decision (the AI
        agent stops following up with this lead).
      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
        contact is NOT deleted — it remains accessible via
        `GET /v1/contacts/{id}`. To permanently delete the contact,
        use `DELETE /v1/contacts/{id}`.

        The target deal must exist, belong to the calling account, and have
        `pipeline_stage_id IS NOT NULL` — otherwise `404 not_found`. The ID of a
        contact absorbed by a merge also returns `404 not_found`: remove the
        surviving contact. If the merge is undone from the dashboard, the
        contact becomes accessible again under its ID. An archived contact stays in
        its pipeline: `422 business_rule_violation` (`details.reason` =
        `contact_archived`).
      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
        by pipeline (in dashboard order), then by `stage_order ASC`.
        Each stage names its pipeline (`pipeline.name`,
        `pipeline.is_default`): an account with several pipelines can have
        two stages with the same name. Each stage includes a
        `contact_count` computed in real time (the number of the account's contacts
        currently in that stage, excluding archived
        and merged contacts — useful for pipeline KPIs). A stage hidden in
        the dashboard is still listed, flagged `is_hidden`.

        ### Non-paginated response

        The endpoint returns **all of the account's stages in a single call**
        (usually < 20 per account, max 50). The response format stays
        `list V2`-compatible for integration consistency
        (`pagination.next_cursor=null`, `pagination.has_more=false`,
        `pagination.limit` and `pagination.total_count` equal the number of
        stages returned).

        ### Read-only

        Creating, editing, and reordering stages is done in the
        dashboard — no POST/PATCH/DELETE `/v1/pipeline/stages` endpoint
        is exposed.
      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
        sort: `last_message_at DESC, id DESC` — conversations with a
        recent exchange come first. `last_message_at` is `NOT NULL` in the table.

        ### Read-only

        The public API only exposes the list of threads. The individual
        messages of a conversation aren't available through the
        public API yet — the payload is limited to the thread's metadata.
        To send an email, use `POST /v1/emails/send`.
      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.
        The email is created with `status="queued"`, `priority=1`,
        `type="transactional"` and delivered asynchronously by the email
        worker — the 201 response confirms the email was queued, not that it
        was delivered.

        ### Sender resolved server-side

        The sender (`from`) is derived from the account's default sender
        (`client_email_senders.is_default = true`). If no default
        sender is configured, the request returns
        `400 no_sender_configured`.

        ### Idempotency

        The `Idempotency-Key` header is NOT supported on this route — a
        naive retry will create a second send. An internal idempotency key is
        generated server-side (`api-transactional/{clientId}/{ts}`) only to
        protect against duplicate database inserts.

        ### Test mode

        `"test": true` in the body lets you validate an integration without
        targeting a real contact: personalization variables are
        filled with sample values, the subject is prefixed with
        `[TEST]`, and recipient compliance checks are
        bypassed. In exchange, the recipient must be an active member
        of the account's team (otherwise 400 `recipient_not_allowed`), and
        a quota of 50 tests per rolling 24 hours applies, shared with the
        platform's test buttons (otherwise 429 `test_quota_exceeded`).
      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
        API key. Subscriptions created by another key on the same account
        are NOT visible here (filter `client_id AND api_key_id`).

        ### No cursor pagination

        The maximum of 10 subscriptions per account always fits on a
        single page. The response returns `pagination.next_cursor=null` and
        `has_more=false`.

        ### Secret never returned

        The HMAC secret used to sign outbound payloads is only
        returned on creation (`POST /v1/webhooks`). It isn't
        returned here — to rotate it, delete the subscription and
        create a new one.
      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`
        to `url` every time a subscribed event occurs.

        ### HTTPS URL required

        The URL must use the `https://` scheme — `http://` is rejected to
        avoid leaking payloads in cleartext.

        ### Limit of 10 subscriptions per account

        Beyond 10 active subscriptions on the same account (across all
        keys), returns `429 subscription_limit_reached`. Delete
        an existing subscription before you create a new one.

        ### HMAC secret returned only ONCE

        The 201 response includes `secret` in cleartext (64 hex characters, 256 bits
        of entropy). You MUST store it immediately — you use it to
        verify the signature of outbound payloads
        (`X-Capturia-Signature`), and the API never returns it again.
        The secret is encrypted in the database after this response.

        ### Scoped to the creating key

        The subscription belongs to both the account AND the API key that
        created it. The `GET /v1/webhooks` and
        `DELETE /v1/webhooks/{id}` endpoints only see the current key's
        subscriptions — a subscription created by another key on the same
        account can't be listed or deleted through the API.
      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
            in cleartext only ONCE — store it immediately.
          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
        must belong to both the calling account AND the calling
        API key (filter `client_id AND api_key_id`) — otherwise
        `404 not_found`.

        ### No soft delete

        Deletion is immediate and irreversible — to resume the
        event stream, create a new subscription with
        `POST /v1/webhooks` (a new secret will be issued).
      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
        the payload shape delivered by a webhook subscription** — the same
        payload builder powers real deliveries and this list.

        ### What it's for

        - **`performList` in Zapier/Make apps**: when a user tests
          a trigger, the app shows real recent samples, guaranteed
          to match what a real delivery will send.
        - **Integration debugging**: inspect what would be delivered without
          waiting for a new event.

        The list combines two sources, from newest to oldest:

        - events **recorded in the account's delivery queue**,
          with the exact data that was or will be delivered;
        - supplemented by events from the **activity log** that the queue
          didn't record (no subscription was listening to them at the time),
          formatted by the same builder.

        So it works even when no webhook subscription exists.
        Events emitted outside the log (`lead_updated`, `note_created`,
        `task_created`, `sms_received`, `email_sent`, `call_ended`…)
        only appear once a subscription listens to them: as long
        as none does, they aren't recorded anywhere. Internal
        event types (audit) never appear, and
        sensitive keys in the `changes` field are redacted just as they are on
        delivery.

        ### Difference from a real delivery

        Each item's `id` field is the event's identifier at its
        source — on a real delivery, `id` is the delivery's unique
        identifier (`X-Capturia-Delivery`). The `data.system` field is
        always `null` in a sample: it is resolved at delivery
        time. The shape and every other field are identical.

        ### No cursor pagination

        This is a feed of recent samples, capped at 25 items
        (`limit`, default 10). The response returns
        `pagination.next_cursor=null` and `has_more=false`.
      security:
        - ApiKeyAuth:
            - webhooks:manage
      parameters:
        - name: event
          in: query
          required: false
          description: |
            Restricts results to events of this type (a catalog value,
            e.g. `lead_created`, `booking_created`, `automation_completed`).
            A value outside the catalog returns `400 validation_error`
            (`reason: unknown_event_type`).
          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
                                the event. A correlation key for finding
                                the contact in a connected CRM. `null` when
                                the event doesn't involve a lead (team,
                                tags, stages…).
                              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
                                at delivery time. Always `null` in a
                                sample.
                              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
        departs from the standard v1 API conventions in several ways,
        documented below. The pre-suasion gateway (`demo.capturia.io`) calls it
        when a visitor completes a pre-suasion tool form published on a
        Capturia account's custom domain.

        ### Authentication: defense in depth

        No Bearer API key (the endpoint is called by a public gateway, not by a
        third-party client). Three stacked layers protect the endpoint:

        1. **IP rate limit** (`RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT` requests per
           `LEAD_WINDOW_SECONDS`) — cuts high-frequency noise.
        2. **HMAC signature** `X-Capturia-Signature: t=<unix>,v1=<hex>` —
           Stripe webhook pattern, SHA-256 over `${timestamp}.${rawBody}`,
           5-minute replay window, two-key rotation (`PRESUASION_HMAC_SECRET` +
           `PRESUASION_HMAC_SECRET_PREVIOUS`). Log-only mode by default, switched
           to fail-closed with `PRESUASION_HMAC_REQUIRED=true`.
        3. **Cloudflare Turnstile token** (`turnstile_token` body field)
           verified through siteverify with `idempotency_key` = the body's
           `submission_id` (stable across retries of the same submission — a
           token is single-use, and Cloudflare replays the original verdict for
           the same key), falling back to the request's `request_id` when the
           submission carries no `submission_id`. Log-only mode by default,
           switched to fail-closed with
           `PRESUASION_TURNSTILE_REQUIRED=true`.

        In addition, a **quota per `proposition_slug`** (100 submissions per
        UTC day, Upstash KV) limits the blast radius of a targeted attack even
        if the other layers are bypassed.

        The target account is resolved server-side through `proposition_slug` →
        `presuasion_instances.client_id` — `client_id` is never accepted from
        the body.

        ### Custom body and response format

        The body follows a schema specific to this endpoint
        (`PresuasionSubmissionPayload`). The 200 response returns a flat object
        `{success, contact_id, contact_created, activity_id}` instead of the
        standard v1 `{data: ...}` envelope. Errors return
        `{error: "string", issues?: [...]}` instead of `ErrorEnvelope`.

        ### Behavior

        - Resolves the target account through an active `proposition_slug`.
        - Finds the contact with the shared funnel engine: matched by email
          (case-insensitive, secondary emails included), then by a phone number
          already known for this account — the existing contact is then
          enriched with the submitted email as a secondary email, never
          duplicated and never failing on a uniqueness error. Names are filled
          in without overwriting, the phone number is set if it was missing (a
          number already held by another contact stays on the submission), and
          the implied SMS consent triplet is refreshed on every submission
          (subject to the STOP guard). On the tokenized path
          (`identity_confirmed`), the link's contact receives the same additive
          enrichment.
        - Capture routing set on the tool: a contact **created** by the
          submission starts in the tool's entry column; the tool's automatic
          tags are added to every captured contact (created or matched, through
          the email or the link path); a matched contact that is in no column
          enters one through the tool. A contact already placed is never moved.
        - Inserts a `presuasion_submission` activity into the contact's
          timeline with a formatted human-readable `Q/R` block + raw `metadata`
          (responses, result_data, tool_name, proposition). The tool is
          identified there by its instance: `metadata.tool_id` and
          `metadata.proposition_id` carry the identifier of the resolved
          proposal, like captures from embedded tools.

        ### Rate limit

        Emits the legacy `X-RateLimit-*` headers (IP counter) — not the
        RFC 9331 `RateLimit-*` headers used by the rest of the v1 API.
      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
            `${timestamp}.${rawBody}` with `PRESUASION_HMAC_SECRET`. 5-minute
            replay window. Optional in log-only mode; required
            when `PRESUASION_HMAC_REQUIRED=true` (401 response otherwise).
      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`
            activity created in the timeline.
          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
            convention** — custom `{error, issues?}` body that does not follow the
            v1 catalog's `ErrorEnvelope`.
          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,
            expired (5-minute window), or invalid. Returned only when
            `PRESUASION_HMAC_REQUIRED=true` (fail-closed mode). In log-only
            mode, unsigned requests go through and a warning is
            emitted for observability.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Invalid signature
        '403':
          description: |
            Cloudflare Turnstile token missing, expired, or rejected by
            siteverify. Returned only when
            `PRESUASION_TURNSTILE_REQUIRED=true` (fail-closed mode).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Captcha verification failed
        '404':
          description: |
            Unknown or disabled `proposition_slug`
            (`presuasion_instances.is_active = false`).
          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
            contact, activity, or enrichment):

            * **Unlinked demo** — the `proposition_slug` exists but the
              proposal is not linked to any Capturia account
              (`client_id IS NULL`). A demo without an account never creates a
              contact: the submission is explicitly refused rather than
              faked as a success, and the refusal is logged on the Capturia side.
            * **Invalid link identity** — `identity_confirmed: true` while
              the `token` no longer resolves to any contact of this account. No
              contact is created from an identity that cannot be found. This
              variant is evaluated after the per-`proposition_slug` quota:
              when the signature and Turnstile are valid, it consumes one
              unit of the daily quota. The unlinked demo consumes
              none.
          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:

            * **IP rate limit exceeded** — `{error: "Too many requests"}` body,
              `X-RateLimit-*` headers (legacy convention).
            * **Per-`proposition_slug` quota exceeded** (100/UTC day) — body
              `{error: "Slug quota exceeded"}` with the standard
              `Retry-After` header (seconds until UTC midnight) in addition to the
              `X-RateLimit-*` headers.
          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
            insert). **Legacy convention** — custom body
            `{error: "string"}` instead of `ErrorEnvelope`.
          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
        `Authorization: Bearer cap_live_...` header. Legacy keys in the `cap_<hex64>` format
        are also accepted until 2027-05-03.

        ## Available scopes

        Each key carries a list of scopes that determine which operations it
        can perform. Scopes are checked server-side on every request.
        20 scopes in total: 8 read scopes, 10 write/trigger scopes,
        and 2 ingestion scopes.

        ### Read scopes

        - `contacts:read` — Read contacts
        - `pipeline:read` — Read pipeline deals and stages
        - `conversations:read` — Read conversation threads (chat, SMS, call, email)
        - `bookings:read` — Read bookings
        - `quotes:read` — Read quotes
        - `tags:read` — Read tags
        - `automations:read` — Read automations
        - `email:read` — Read email threads

        ### Write scopes

        - `contacts:write` — Create/update contacts
        - `contacts:delete` — Permanently delete contacts (only someone who can delete leads in Capturia can grant it; included only in `Full integration`)
        - `pipeline:write` — Create/update deals
        - `bookings:write` — Create/update bookings
        - `quotes:write` — Create/update quotes
        - `tags:write` — Create/update tags
        - `automations:trigger` — Manually trigger an automation
        - `email:send` — Send a transactional email
        - `webhooks:manage` — Create/update outbound webhooks
        - `campaigns:launch` — Preview, then launch an SMS follow-up campaign (MCP connector, two-step confirmation required)

        ### Ingestion scopes

        - `leads:capture` — Push leads from an external funnel (the most common scope for partner integrations)
        - `events:ingest` — Push custom events that trigger automations (`POST /v1/events`)

        ## Presets

        To make key creation easier, 5 scope presets are offered when you create a key:
        `Lead capture` (`leads:capture` only), `Read only` (all `:read`),
        `Read + write` (all `:read` + `:write` + `leads:capture` + `events:ingest`),
        `Full integration` (all scopes), `Custom` (free selection).

        Full documentation: https://capturia.io/en/developers/api/v1/authentication
  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
        routes are accessible: `:read` for reads, `:write` for mutations,
        plus a few specific scopes (`contacts:delete`, `leads:capture`,
        `events:ingest`, `automations:trigger`, `email:send`, `webhooks:manage`,
        `campaigns:launch`). You change an active key's scopes in the
        key's Permissions tab, in Capturia.
      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
        the database — recomputed at runtime).

        - `lead_capture` — scopes = exactly `[leads:capture]`
        - `read_only` — all `:read` scopes
        - `read_write` — all `:read` + all `:write` + `leads:capture` + `events:ingest`
        - `full_integration` — all available scopes
        - `custom` — any combination that doesn't match a known preset
      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`.
        Use it as a smoke test — a developer integrating Capturia can quickly
        confirm that their key works and read the details it exposes
        (target client, scopes, preset, expiration, applicable rate limit).

        The key's secret is never returned — only `key_prefix` (the first 8-12
        characters, such as `cap_live_…`) serves as a human-readable identifier
        in the dashboard.
      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
            dashboard so you can visually identify the key without exposing the secret.
          example: cap_live_a1b2c3
        key_format_version:
          type: integer
          description: |
            Version of the key format (1, 2, …). Lets the format evolve
            without breaking existing keys. New keys
            always use the latest version.
          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
            set (rare; system accounts).
          example: Acme Inc.
        scopes:
          type: array
          description: |
            Effective scopes carried by the key (filtered against the list of
            valid scopes — any unknown scopes in the database are dropped).
          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`,
            `paused`, `revoked`. A `paused` or `revoked` key fails
            authentication — if you see `active` here, the key
            works.
          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`
            if never used.
          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
            automatically. An expired key returns 401 `expired_api_key`.
          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`).
        A bucket covers one hour or one day, depending on the `granularity` requested
        on `GET /v1/usage`. Latencies are in milliseconds
        (server-side percentiles, `null` if no request was measured in the window).

        Each request falls into exactly one family: `status_2xx`, `status_4xx`,
        `status_5xx`, `rate_limited_count`, or `status_unknown`. The rare 1xx and
        3xx statuses belong to no family but are still counted in
        `request_count`.
      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`
            granularity: aligned to the top of the hour. For `day`: aligned
            to 00:00:00 UTC.
          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
            their outcome (rate limit rejections and unknown outcomes included).
          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,
            etc.). Rejections from a rate limiter are not included here: they are counted
            in `rate_limited_count`. A business-rule 429 (trigger called
            too often, test send quota, webhook subscription cap)
            is a client error counted here, and it consumes the hourly quota.
          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
            quota, brute-force protection, an endpoint's per-IP-address
            limit). A family separate from `status_4xx`, never included
            in it. These requests don't consume the hourly quota — useful for
            sizing your usage.
          example: 5
        status_unknown:
          type: integer
          minimum: 0
          description: |
            Number of requests whose outcome wasn't recorded (response still
            in progress at aggregation time, or recording the outcome
            failed). Never counted as successes or as errors; excluded
            from error rate calculations.
          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`
        (no aggregated latency — percentiles aren't additive).
      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
        (`api_key_id` = current key). The time range (`from` → `to`) must
        be strictly positive and must not exceed 90 days
        (otherwise 400 `invalid_request`).

        `buckets` are sorted by `bucket_start ASC`. A window with no traffic
        is omitted from the array (buckets aren't zero-padded).
      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
        marketing source in the pipeline (`Source : Google Ads / Facebook campaign X`).
        You can also send each sub-field flat (`utm.source`); see
        `LeadCapturePayload`. An unknown sub-field is not rejected: it is
        ignored and named in the response's `ignored_fields`.
      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,
        pre-suasion quiz). Stored to enrich the SMS bot's context and feed
        lead scoring. An unknown key is not rejected: it is ignored and named
        in the response's `ignored_fields`
        (`qualification_answers[0].ma_cle`).
      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
        optional. Without `pipeline_slug`, the account's default pipeline is
        targeted. If `stage_slug` is omitted, the pipeline's default stage is used.
        `assigned_sales_rep_email` must match a member of the client's team
        (otherwise the returned `assignment_status` is `email_not_found`).

        You identify the pipeline and the stage by their **text identifier** (slug),
        never by their technical identifier (UUID): `id`, `stage_id`, or any other
        unknown sub-field is ignored and named in the response's `ignored_fields`
        (`unknown_field`). A slug that matches nothing is rejected with a
        422 (`pipeline_not_found`, `stage_not_found`).

        You can also send each sub-field flat (`pipeline.stage_slug`); see
        `LeadCapturePayload`.

        On an existing contact, these fields are only written where the contact is
        empty: the ones it keeps are named in `ignored_fields`
        (`existing_contact_kept`).
      additionalProperties: true
      properties:
        pipeline_slug:
          type: string
          maxLength: 100
          description: |
            Text identifier of the target pipeline. Copy it from the dashboard:
            Administration, API Keys, section "Your pipeline and stage
            identifiers".
          example: ventes-b2b
        stage_slug:
          type: string
          maxLength: 100
          description: |
            Text identifier of the target stage in the pipeline, copied from the
            same place as `pipeline_slug`. Unique within its pipeline only: two
            pipelines can share the same stage identifier. Without
            `pipeline_slug`, it is looked up in the account's default pipeline.
            If omitted, the pipeline's default stage is used; a pipeline with no
            default stage receives the lead in its first stage that is not won,
            lost, or hidden and has no system role: the stage marked "Default
            stage" next to the identifiers.
          example: nouveau-lead
        deal_value:
          type: number
          minimum: 0
          maximum: 100000000
          description: |
            Estimated deal value in CAD (0 to 100 M). A strictly numeric
            string (`"2500"`, `"1500,50"`) is accepted and converted: key/value
            connectors only send text.
          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:
            a full timestamp is rejected.
          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
            calling client (`client_users.status = 'active'`) who has already
            activated their account (invitation accepted). Otherwise the capture
            continues without assignment and the response returns
            `assignment_status = email_not_found` (not an error).
          example: alex@acme.com
    LeadCaptureMetaAttribution:
      type: object
      description: |
        The visitor's Meta ad attribution, captured on the funnel at submission
        and persisted (first-touch) on the contact. Used to attribute the
        server-side Purchase event (Conversions API) to the original click when
        the deal moves to "won". All fields are optional; providing whatever is
        available improves Meta's match quality. A returning lead does not
        overwrite its original attribution (first-touch).

        The `ad_id`/`adset_id`/`campaign_id` identifiers (and their names) also
        link the lead to the ad that brought it in, visible on its contact and in
        per-ad performance. Connectors hooked up to Meta Lead Ads (Zapier, Make,
        n8n) have these fields in the lead
        payload.

        You can also send each sub-field flat (`meta.ad_id`); see
        `LeadCapturePayload`. An unknown sub-field is not rejected: it is
        ignored and named in the response's `ignored_fields`.
      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
            IP of the calling server). Accepted to improve Meta's match quality.
        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
            consent was collected upstream (your own banner, or a Meta Lead Ads
            lead), and conversion events are sent to Meta.
            `false` = the lead declined ad tracking: no Meta event is sent, no
            tracking identifier (`fbc`/`fbp`) is kept, and the refusal is
            persisted on the contact; it also blocks the contact's future events
            (won sale, qualified lead). Boolean, or the no-code connector
            equivalent: numeric `1`/`0`, or a string
            (`"true"`/`"yes"`/`"oui"`/`"1"` and `"false"`/`"no"`/`"non"`/`"0"`,
            case-insensitive). `null` counts as "field omitted".
          oneOf:
            - type: boolean
            - type: string
            - type: number
            - type: 'null'
          example: false
    LeadCapturePayload:
      type: object
      description: |
        Lead capture body (`POST /v1/leads/capture`). `phone` and
        `sms_consent` are required: Capturia is SMS-first, and capture
        requires an explicit opt-in.

        `phone` is normalized server-side before validation: a 10-digit number
        with no country code is assumed to be NANP (`+1XXXXXXXXXX`), an
        11-digit number starting with `1` gets the `+`, and formats with
        parentheses/dashes (`(514) 123-4567`, `1-514-123-4567`) are accepted.
        For an international number, explicitly include the `+` and the
        country code (e.g. `+33612345678`). A phone delivered as a JSON number
        (a no-code connector's numeric field) is accepted if it is a positive
        integer; see the `phone` property.

        `sms_consent` accepts the boolean `true` or the truthy strings
        `"true" | "yes" | "oui" | "vrai" | "1" | "on" | "checked"`
        (case-insensitive). Any other value is rejected. Zapier, Make, and
        most form builders stringify booleans, so accepting these common
        variants avoids losing valid
        consents.

        Body cap: 1 MB. Above that: 400 `payload_too_large`.

        No-code tolerance: identity fields are normalized server-side
        (case and accents ignored, common aliases such as `firstName`, `Full Name`,
        `phone_number`, `Courriel` mapped to the canonical keys). Unrecognized
        keys are ignored rather than rejected, hence `additionalProperties`,
        and **named in the response's `ignored_fields`**: a capture that
        returns 200 tells you what it did not take (see `ignored_fields`, and its
        one limitation). An optional field sent empty (`""` or `null`) is treated
        as absent rather than rejected. `tags` also accepts a comma-separated
        string (`"chaud, webinaire"`), coerced into an array.

        Flat blocks: connectors that only send key/value pairs
        (the Webhook action of a GoHighLevel workflow, Zapier's Data fields) can
        fill `pipeline`, `utm`, and `meta` with dotted keys, at the root or
        in `customData`: `pipeline.stage_slug`, `utm.source`, `meta.ad_id`.
        An explicit nested block takes precedence, sub-field by sub-field.

        GoHighLevel webhooks: the Webhook action of GHL workflows wraps the
        "Custom Data" pairs in a nested `customData` object. Capturia
        unwraps this wrapper one level: each pair is processed as if it
        were at the root (same aliases, same validation). No configuration
        is required on the GHL side.

        Duplicates: when the same field arrives from several sources, the one kept
        is, in order, the canonical key at the root, the one in `customData`, an
        alias at the root, then an alias in `customData`; an explicit block wins over
        its flat keys. Two exceptions: tags from the root and from
        `customData` are combined, and two blocks (`pipeline`, `utm`, `meta`,
        `custom_fields`) complement each other sub-field by sub-field. A discarded
        copy that carried a different value is named in `ignored_fields`
        (`duplicate_field`). The comparison follows what Capturia stores: a
        phone is compared by its digits, a consent by its meaning, a
        deal value by its amount (`"1500,50"` equals `1500.5`), emails
        and a System identifier case-insensitively; everywhere else case
        matters (slug, click identifier, custom value).
      required:
        - phone
        - sms_consent
      additionalProperties: true
      properties:
        phone:
          description: |
            Phone (normalized to E.164). Expected final format:
            `^\+[1-9]\d{10,14}$` (between 11 and 15 digits after the `+`).
            Also accepted: a positive JSON integer within the JavaScript safe range
            (at most 9007199254740991; every E.164 number fits), converted to a
            string before normalization, for no-code connectors whose source field
            is numeric. Prefer the string: it is the only form that preserves an
            explicit international `+`. Any other number (negative, decimal, zero)
            is rejected with a 422 `invalid_phone` that restates the accepted formats.
          oneOf:
            - type: string
            - type: integer
              minimum: 1
              maximum: 9007199254740991
          example: '+15145551234'
        sms_consent:
          description: |
            Explicit SMS consent. Must be `true` (boolean) or a
            whitelisted truthy string (see the schema description).
            Ambiguous values (`false`, `"no"`, `0`, etc.) are rejected with
            422 `missing_consent`. Accepted at the root of the body or in
            the `customData` object (GoHighLevel webhooks).
          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`,
            `landing-blueprint`). Shown in the pipeline and in the contact's
            activity log.
          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
            URL, auto-tagging). Kept with the lead's ad
            attribution (first-touch). Without `utm.source`, the lead is
            attributed to the `google` source and the `cpc` medium, which
            links it to the campaign whose web origin is google / cpc.
            When `meta.ads_consent` is `false`, the identifier is never
            kept (neither in the attribution nor in the received copy): only the
            google / cpc origin remains.
          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
            client, it is created on the fly. Any name that starts with
            "Campagne :" (any case and spacing) is ignored: this prefix
            is reserved for campaign tags, which Capturia sets on its own based on the
            lead's campaign. The response names it by its position in
            `ignored_fields` (`tags[1]`, reason `reserved_tag`).
          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
            configured on the client side (e.g. `type_propriete`), not by its internal
            UUID: a site's integrator does not know the ids. The capture resolves
            name → definition per client, then fills the contact and the kanban.
            A key with no matching definition is ignored (no error) and
            named in the response's `ignored_fields`
            (`custom_fields.ma_cle`, reason `custom_field_not_defined`). For a
            `select` field, provide the option's `value` (e.g. `maison_isolee`), not its
            label.
          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
            autocomplete. Stored as is for later re-resolution.
          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
            the Capturia dashboard). The captured lead is enrolled in this System.
            Omitted → the main System (historical behavior). A UUID that matches
            none of the account's Systems → `system_not_found` error (422),
            and no contact is created. A System of the account that is not active (archived,
            for example) → the lead goes to the main System, and the response names
            `system_id` in `ignored_fields` (reason `system_not_active`).
          example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
    LeadCapturePipelineEcho:
      type: object
      description: |
        Echo of the **resolved** pipeline routing: the requested pipeline (slug → the
        account's pipeline and stage) when `pipeline.pipeline_slug` was provided, otherwise
        the account's default pipeline, where a lead captured without routing is
        placed. `null` only when no slug is provided AND the account has
        no default pipeline. On a contact created by the call
        (`contact_was_new: true`), this routing is the one applied to the contact. On
        an existing contact that was found (`contact_was_new: false`), the contact
        keeps the pipeline and stage it already has (core fields are
        never overwritten): the echo then reflects the resolved request, not necessarily
        the contact's persisted state. When the contact did not take the requested
        routing, `ignored_fields` says so: `pipeline.pipeline_slug` or
        `pipeline.stage_slug`, reason `existing_contact_kept`.
      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
        captured: this is a setup warning, not an error.
      required:
        - field
        - reason
      properties:
        field:
          type: string
          description: |
            Path of the field. For an unknown name or a discarded copy
            (`unknown_field`, `duplicate_field`), the path as received: a root
            name (`contact_id`), an unwrapped `customData` pair
            (`customData.order_bump`), a sub-field of a block, nested or flat
            (`pipeline.id`, `utm.utm_source`), or the key of a qualification answer
            (`qualification_answers[1].score`). For the other reasons, the canonical
            path, whatever form was received: `pipeline.stage_slug`,
            `system_id`, `custom_fields.budget`, or the position of a tag in
            the cleaned-up list (`tags[1]`). The received name is capped at 64 characters;
            a name shaped like an email address or a phone number comes back
            masked (`[email]`, `[phone]`).
          example: pipeline.id
        reason:
          type: string
          description: |
            - `unknown_field` — a field name the API does not recognize
            - `custom_field_not_defined` — a `custom_fields` key with no definition
              of the same name in the account
            - `reserved_tag` — a tag name reserved for campaign tags
            - `system_not_active` — `system_id` of one of the account's Systems that is
              not active (archived or in draft): the lead went to the main
              System
            - `existing_contact_kept` — existing contact: its pipeline, stage,
              value, closing date, or sales rep already in place were
              kept
            - `sales_rep_not_found` — `pipeline.assigned_sales_rep_email` does not
              match any active member of the account (see `assignment_status`)
            - `submission_not_recorded` — no submission recorded
              (`existing_pipeline_contact`, `idempotent_replay`): tags,
              notes, and qualification answers are not applied; a replay
              also skips `source_label`, the ad attribution (`utm`,
              `gclid`, the ad identity and the `ads_consent` choice of the
              `meta` block, named one by one: `meta.ad_id`), and the routing (`system_id`).
              The `meta` matching identifiers (`fbc`, `fbp`, `fbclid`,
              `event_source_url`, `client_ip_address`, `client_user_agent`)
              follow the first-contact rule instead, as on any existing
              contact
            - `duplicate_field` — the same field arrived from several sources
              (root, `customData`, alias, flat key) and another value was
              kept
          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
        **submission**: recorded (`created`), idempotent replay
        (`idempotent_replay` — identical submission received within the
        5-minute window or via a known `Idempotency-Key`), or contact already being handled
        (`existing_pipeline_contact` — already in an open pipeline stage).
        `contact_was_new` distinguishes a contact created by the call from an
        existing contact found by email or phone. In every case, the
        returned contact ID is the same.

        `ignored_fields` names each received field that the capture did not take (see
        its description for its one limitation: identity and address fields
        already filled in on an existing contact, and its ad attribution already
        set).

        The SMS bot takes over within 30 seconds after a recorded
        submission if consent and configuration are in order. It is
        never triggered for an `existing_pipeline_contact`.
      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
              the call **and** an existing contact found (same email or same
              phone) and then enriched; `contact_was_new` tells them apart.
            - `idempotent_replay` — submission already processed (5-min window or
              Idempotency-Key match), no new lead created
            - `existing_pipeline_contact` — the contact is already in an open pipeline
              stage (neither won nor lost), so it is already being handled:
              no new submission is recorded and the SMS bot is not
              triggered. Core fields and `custom_fields` still enrich
              the existing contact; tags, notes, and qualification
              answers, which belong to the submission, are not recorded
              (`ignored_fields`, reason `submission_not_recorded`). Lets an
              integration (Zapier, Make, FB Lead Ads) re-push a lead without
              disrupting an ongoing conversation.
          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.
            `false` when the capture found an existing contact (same email
            or same phone, secondary contact details included): the contact is
            enriched without overwriting its core fields; its pipeline, stage, and
            sales rep already in place are kept, even if the capture requested
            different ones.
        activity_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            Identifier of the activity log entry created for this capture.
            `null` when `status = existing_pipeline_contact`: no submission
            is recorded for a contact already in an open pipeline stage.
        sms_conversation_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            Always `null` in the current response: the SMS bot starts
            asynchronously after the capture, so no conversation exists yet
            when the response is produced. Field kept for compatibility;
            do not rely on it to detect that the bot has started.
        pipeline:
          oneOf:
            - $ref: '#/components/schemas/LeadCapturePipelineEcho'
            - type: 'null'
          description: |
            Echo of the resolved pipeline routing: the requested pipeline, otherwise the
            account's default pipeline. `null` when no slug is provided and
            the account has no default pipeline. On an existing contact
            (`contact_was_new: false`), the contact keeps the pipeline it already
            has; see `LeadCapturePipelineEcho`.
        original_created_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            For an `idempotent_replay`: timestamp of the original creation
            (useful to tell an immediate replay from a replay several
            minutes later). `null` for a fresh `created`.
        assignment_status:
          type: string
          description: |
            Result of the sales rep **resolution** if
            `pipeline.assigned_sales_rep_email` was provided:
            - `not_requested` — no assignment email provided
            - `assigned` — the requested sales rep exists and is active. They are assigned
              to a contact created by the call; an existing contact already assigned
              to someone else keeps its current sales rep (core field not
              overwritten; see `contact_was_new`).
            - `email_not_found` — an email was provided but no active member with an
              activated account matches it. The capture continues without assignment: a
              contact created by the call starts with no sales rep, and an existing contact keeps
              its assignment as is.
          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
            present, empty when everything was taken. At most 50 entries, sorted by
            usefulness: first the specific reasons (`existing_contact_kept`,
            `sales_rep_not_found`, `system_not_active`, `submission_not_recorded`,
            `reserved_tag`, `custom_field_not_defined`, `duplicate_field`), then
            unknown names: sub-fields of a block, `customData` pairs,
            root keys. A connector that automatically adds dozens of
            fields at the root therefore never pushes a hand-mapped field out of
            the list.

            One limitation: on an existing contact, the list names the fields of the
            `pipeline` block it kept, but not, one by one, its identity and address
            fields already filled in, nor its ad attribution
            already set (first-contact rule), which are kept as well
            (`contact_was_new: false`).

            With an `Idempotency-Key` already seen, the original response is replayed
            as is, list included.
          items:
            $ref: '#/components/schemas/LeadCaptureIgnoredField'
        ignored_fields_truncated:
          type: boolean
          description: |
            `true` when the capture ignored more fields than the list
            names (cap of 50).
        message:
          type: string
          description: |
            Human-readable message describing the result (useful for logs/debugging). When
            `ignored_fields` is not empty, it ends with
            "N received field(s) were not applied — see ignored_fields.", or
            with "More than N received fields were not applied — see
            ignored_fields." when the list is truncated.
          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
        required among `external_id`, `email`, `phone`. The server resolves the contact
        in the order `external_id`+`external_source` → `phone` → `email`; the identity
        fields you send enrich the contact non-destructively (a
        value that is already set is never overwritten).
      properties:
        external_id:
          type: string
          maxLength: 255
          description: |
            Identifier of the contact in the source system (e.g. a HubSpot ID, a
            user ID from your backend). Source idempotency: two events
            with the same `external_id` + `external_source` always resolve
            to the same contact.
        external_source:
          type: string
          maxLength: 100
          description: |
            Namespace of the `external_id` (e.g. `hubspot`, `shopify`,
            `mon-backend`). Default: `api`. Ignored without `external_id`.
        email:
          type: string
          format: email
          maxLength: 255
        phone:
          type: string
          description: |
            Accepted formats: E.164 (`+15145551234`), 10-digit NANP,
            11 digits with country code, or formatted variants — normalized
            to E.164 on the server.
        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
            identifiers you send, the request returns `404 contact_not_found` and
            nothing is recorded.
    EventIngestPayload:
      type: object
      additionalProperties: false
      description: |
        Strict contract: an unknown top-level key (or one inside `contact`) is
        rejected with `422 validation_error` — this protects you from silent
        integration typos. Free-form data goes under `payload`.
      required:
        - event
        - contact
      properties:
        event:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Event name, in the account's namespace. Compared as-is
            (case-sensitive) to the name configured on the automations'
            "Custom event" trigger.
          example: abandon_panier
        contact:
          $ref: '#/components/schemas/EventIngestContact'
        payload:
          type: object
          additionalProperties: true
          description: |
            Arbitrary event data. Each top-level key is
            available to automation messages through `{{event.<key>}}` (a
            nested object is serialized as JSON). The stored content is capped
            (number of keys, depth, total size).
        custom_fields:
          type: object
          additionalProperties: true
          description: |
            Custom fields of the contact, keyed by the definition's `name`
            (same contract as `/v1/leads/capture`). A key with no matching
            definition is silently ignored. Additive merge: the
            new value wins on collision.
        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:
            the event is recorded on the contact (for traceability) but the contact
            is not modified and no automation is enrolled.
        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
            active automation listens for this event name (the event is
            still recorded on the contact).
        event:
          type: string
        message:
          type: string
    EventRegistrationCreate:
      type: object
      description: |
        Body of `POST /v1/event-registrations`. The contact must exist in the
        account (`POST /v1/contacts` or `POST /v1/leads/capture` create it, along with
        its consents). You identify the event by `event_id` OR by
        `event_slug`, the address at the end of its registration link — never
        both.
      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,
            as on its registration page.
        language:
          type: string
          enum:
            - fr
            - en
          description: |
            Language of the messages. If omitted: the event's first language.
          example: fr
    EventRegistration:
      type: object
      description: |
        Registration of a contact for an edition of one of the account's events (webinar,
        challenge, summit, online event). One registration per person and
        per edition.
      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
            event does not offer is replaced by the event's first language.
          example: fr
        created:
          type: boolean
          description: |
            `true` if the call created the registration, `false` if the contact was already
            registered for this edition (the same registration is returned).
          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
        field may be missing in the database. `custom_fields` is a free-form object validated
        against the account's custom field definitions.
      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
            an integration via `POST /v1/contacts`), `ai_assistant` (created by an
            AI assistant connected to the connector), or the origin of a capture
            (form, booking, inbound call…).
          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
            `client_custom_field_definitions`. Additive on read (no fixed schema).
        address:
          type:
            - string
            - 'null'
          description: |
            Full address as entered or validated (Google). The components
            below are split out of it when the address has been validated; `city` and
            `region` can also be set on their own (form, import).
          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.
            Format: base64url encoding of an object `{v:1, f:string, s:asc|desc, val:string, id:string}`.
            Clients must not parse this value — treat it as opaque.
          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).
            Use `next_cursor` instead.
        total_count:
          type: integer
          deprecated: true
          description: |
            Legacy field. Estimated total — may differ from the actual count on large tables (PostgreSQL `estimated`).
            Sunset 2027-05-03. An exact count isn't guaranteed and is expensive to compute.
          minimum: 0
    ContactCreate:
      type: object
      description: |
        Body for creating a contact (`POST /v1/contacts`). An email or a
        phone number is required. A person who's already known (email or
        phone number, secondary identities included) returns 409
        `duplicate_contact` with the existing contact, rather than a new insert.
      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
            that can be normalized to E.164 (e.g. `+15145551234`). A number that's too
            short, incomplete, or followed by an extension is rejected (`400`,
            `validation_error`, reason `invalid_phone` on the `phone` field),
            just like when updating a contact.
          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
            definition). Validated against `client_custom_field_definitions` on
            write — an unknown field, an incompatible type, or a value
            that's too long returns 400 `invalid_custom_fields`, under the key
            you sent. Maximum lengths: text 1,000 characters, long text
            4,000, URL 2,048 (`http(s)://`), email 320, phone 30; a
            list only accepts one of its options. On read, the keys
            returned are the definition ids (storage form).
        system_id:
          type: string
          format: uuid
          description: |
            Target System of the created contact (UUID of one of the account's Systems). The contact
            is engaged in this System. Omitted → main System (historical
            behavior). UUID unknown for this account → `system_not_found` error
            (422), no contact created; archived System → silent fallback to the
            main System.
          example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
    ContactWithTags:
      description: |
        Contact enriched with its tags (response of `GET /v1/contacts/{id}?expand=tags`).
        `client_contact_tags` is the join table and contains the full nested tag.
      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
                          campaign's tag), `null` for an account tag.
    ContactUpdate:
      type: object
      description: |
        Body for updating a contact (`PATCH /v1/contacts/{id}`). All fields
        are optional, but at least one must be provided. `custom_fields` is merged
        with the existing values (not replaced).
      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
            provide are kept). Passing `null` clears all fields.
    ContactBatchItem:
      type: object
      description: |
        One contact in the `POST /v1/contacts/batch` batch. The email is required:
        it serves as the upsert and deduplication key within the batch. The target System
        is set once for the whole batch (top-level `system_id`), never per
        contact; custom fields can only be sent through `POST /v1/contacts`.
      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.
        Backed by the `chat_sessions` table — a thread groups a visitor's messages
        with an AI or human agent. Contact details (email, phone, first_name, last_name)
        are snapshotted on the session; `contact_id` points to the unified contact when
        a match has been found.

        Threads are created and messages are sent from the front end / chat SDK — the public API
        is read-only.
      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
            account can have several chatbots of the same type). `null` for a
            thread without an agent.
          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`).
        Sorted in natural reading order (created_at ASC). The role indicates the
        message direction; `author_kind` tells you who wrote it.
      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:
            - `user`: visitor / lead
            - `assistant`: reply to the visitor, from the AI agent or from a sales rep
              on the team (see `author_kind`)
            - `system`: system message (init, context, instructions)
          enum:
            - user
            - assistant
            - system
          example: user
        content:
          type: string
          description: |
            Text content of the message, ready to display (assistant messages may
            contain light Markdown). The agent's internal control markers are removed:
            a message the agent sent as several bubbles is rendered as separate
            paragraphs. It can therefore be an empty string if the message only
            carried control markers; the row is still returned so the pagination
            has no gaps.
          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:
            - `visitor`: the visitor / lead
            - `agent`: the AI agent
            - `human`: a sales rep on the team who replied to the visitor from
              the Inbox (their first name is in `author_name`)

            `null` for a system message.
          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
            only when `author_kind` is `human`; otherwise `null`.
          example: null
    Booking:
      type: object
      description: |
        Bookings in the schedule of the API key's account: the appointments
        your team holds on your calendars (booking page, dashboard, AI agents,
        automations, integrations). A key only sees its own account's
        bookings.

        The API lets you list, book (through the same service as the booking
        page and the dashboard, with the same rules), and cancel (soft cancel:
        `status="cancelled"` + `cancelled_at`). There is no PATCH: to change
        the time, cancel and book again, or reschedule from the dashboard.
      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:
            - `confirmed` — booked and active (value set on creation through the API)
            - `cancelled` — cancelled
            - `completed` — held
            - `no_show` — the guest did not show up
          example: confirmed
        source:
          type:
            - string
            - 'null'
          description: |
            Booking source: `api` for a booking made through the API or the
            connector; other values: `booking_page`, `pipeline`, `manual`, `chat`,
            `automation`.
          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
        account's calendars (`event_type_id`), at the requested time
        (`start_datetime`) — never another. The duration is the calendar's:
        `end_datetime` is rejected. `status` and `source` are set by the
        server (`confirmed` / `api`).

        The guest is either an existing contact of the account (`contact_id`)
        or `guest_name` + `guest_email`: the contact is then found by its
        contact details, or created, as on the booking page. With both, the
        details you provide take precedence for this booking.
      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
            one from another account, is rejected with `404 resource_not_found`
            (`details.field = "event_type_id"`).
          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
            offset, the time is read as UTC. Prefer an explicit offset
            (`2026-11-10T10:00:00-05:00`).
          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
            another account is rejected with `404 resource_not_found`
            (`details.field = "contact_id"`); an archived contact, `422`
            (`contact_archived`); a contact without an email and without
            `guest_email`, `400` (`contact_without_email`).
          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
            honored or rejected with its reason, never replaced: unknown to the
            account, `404 resource_not_found` (`details.field = "host_id"`); not a
            host of this calendar, `422 host_not_on_calendar`; busy or outside
            their hours, `422 host_unavailable`. Without `host_id`: the contact's
            sales rep if they can take the slot, otherwise the calendar's
            rotation.
          example: 5c6d7e8f-9a0b-4c2d-8e4f-5a6b7c8d9e0f
        timezone:
          type:
            - string
            - 'null'
          description: |
            Guest's IANA time zone (e.g. `America/Montreal`). If absent: the
            contact's, otherwise the calendar's. An unknown time zone or a bare
            offset (`-04:00`) is rejected: `400 validation_error`, reason
            `invalid_timezone`.
          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
            question's identifier, in the form `{ "value": ... }`. `value` is
            text, a number for a numeric question, one of the options for a
            choice question, or a list of options for a multiple-choice
            question. A calendar with a required question rejects a booking
            without its answer: `400 validation_error`,
            `details.field = "form_responses.<id>"`. Every form rejection
            includes `details.questions`, the calendar's questions (`id`, `label`,
            `type`, `required`, `options`).
          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
        not deleted: its status changes to `cancelled` and `cancelled_at` is
        timestamped.
      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:
        `draft` → `sent` → `signed` (terminal) or `cancelled`. Archived quotes
        (`archived_at IS NOT NULL`) are excluded from every list and from PATCH/SEND
        operations — the public API does not expose them.

        Amounts (`subtotal`, `tps_amount`, `tvq_amount`, `total`) are computed
        server-side from the blocks (the quote's line items) and cannot be edited via PATCH.
        Creating a quote through the API initializes an empty quote (`blocks=[]`) in `draft` status;
        you add blocks and finalize the quote from the dashboard.
      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:
            - `draft` — created, not sent yet
            - `sent` — sent to the prospect (`sent_at` timestamped)
            - `signed` — signed by the prospect (`signed_at` timestamped, terminal)
            - `cancelled` — cancelled (terminal for sending)
          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
            resolved server-side. Non-null = no taxes (amounts recalculated to 0), and the reason is
            shown to the prospect below the total. A quote with a positive amount, no taxes,
            AND no reason can be neither sent (`422 untaxed_quote`) nor paid.
        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),
            `installments` (N equal installments), `recurring` (the total billed every month
            until the sales rep stops it).
          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
            after the first online payment) or `manual` (the customer pays each
            installment by bank transfer/Interac and the sales rep records it — nothing is charged).
          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:
            `signature_then_payment` (sign first, then pay — default),
            `payment_then_signature` (pay first; the signature completes the file),
            `payment_only` (sale without a contract, no signature requested),
            `signature_only` (nothing to pay: the amount is presented as complimentary, and
            signing is the only step). Snapshot of the account setting taken at
            creation; editable while `status="draft"`.
          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
            charge, approved manual payment). `null` until a payment has been
            received. Historical milestone: a refund does not reset it to `null`. It is
            the completed-sale indicator for `signature_payment_order="payment_only"`
            (no signature is ever recorded there: `status` stays `sent`/`viewed`, and
            `signed_at` stays `null`).
        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"`.
        Capturia assigns the number (`quote_number`): providing one is rejected
        (400 `validation_error`, reason `read_only`). Omitted fields take the
        account's defaults (validity, payment terms, tax profile).
      additionalProperties: false
      properties:
        contact_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            The account's contact (`/v1/contacts`) to link the quote to. Each omitted
            `prospect_*` field is read from the contact; each field you provide takes precedence. A contact
            from another account → 400 `validation_error` (reason `not_found_in_tenant`).
        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
            (in days, starting today, Montreal time). `null`: no date.
          example: '2026-06-30'
        payment_terms:
          type:
            - string
            - 'null'
          description: |
            Payment terms (free text). Omitted: the account's terms. `null`: none.
          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
            setting (snapshot taken at creation). Value outside the list → 400 `validation_error`.
        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
        required, otherwise 400 `no_fields`. Amounts (`subtotal`, `total`, etc.) are
        not editable — they are computed server-side from the blocks (managed from the dashboard).

        `status` only closes the quote (`declined`, `cancelled`); sending goes
        through `POST /v1/quotes/{id}/send`.
      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
            server-side. The plan is locked once the first payment is collected (409 `payment_plan_locked`).
            Consistency between fields (a deposit with no value, an installment count out of bounds)
            is validated server-side → 400 `validation_error`.
        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
            received (bank transfer, Interac) are recorded by the sales rep, and nothing is charged. Value
            outside the list → 400 `validation_error`.
        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
            a draft; once it has been sent → 409 `signature_payment_order_locked`. Value
            outside the list → 400 `validation_error`.
        tax_exempt_reason:
          type:
            - string
            - 'null'
          maxLength: 200
          description: |
            Written reason for a tax-exempt sale; `null` makes the quote taxed again. An
            empty string or one longer than 200 characters → 400 `validation_error`. Totals
            are recalculated server-side when it changes.
        status:
          type: string
          enum:
            - declined
            - cancelled
          description: |
            Closes the quote: `declined` (the lead declined it) or `cancelled`. The other
            statuses are never set through PATCH: `sent` comes from
            `POST /v1/quotes/{id}/send` (the email goes out), `viewed` and `signed` from the
            lead, `expired` from the validity date. Value outside the list → 400
            `validation_error`. A signed or paid quote can no longer be closed → 409
            `status_locked` (nothing is written).
    QuoteSendResult:
      type: object
      description: |
        Response from `POST /v1/quotes/{id}/send`: the quote was emailed to the
        lead, and `status="sent"` and `sent_at` are set.
      required:
        - data
      properties:
        data:
          type: object
          description: |
            Partial snapshot of the quote after the update (a subset of fields compared to the
            full `Quote` — the handler does not reselect every field).
          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.
        Listed in stable alphabetical order (`name ASC, id ASC`). The color is
        free-form (hexadecimal by convention) and the category lets you group
        several tags (e.g. `priority`, `industry`, `lifecycle`).
      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
            `#6b7280` (neutral gray) if not provided at creation.
          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
            tag created by the account. A key that starts with `campaign:` identifies
            a campaign's tag ("Campagne : <name>"): Capturia applies it to
            every lead of that campaign (campaign number, tag, or
            web source) and removes it when the lead's campaign changes. The
            name follows the campaign's name, but the key never changes: a third-party
            tool can rely on it to count a campaign's leads
            (`GET /v1/contacts?tag=<id>`, paging until
            `has_more: false`, never through `pagination.total_count`, which is
            only an estimate). These tags cannot be applied or
            removed through the API (`409 tag_managed_by_capturia`).
          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.
        `color` defaults to `#6b7280` on the server if omitted.
      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
        event (lead captured, stage change, booking made, etc.) or by an explicit
        API call (`POST /v1/automations/{id}/trigger`). An archived automation
        (`archived_at IS NOT NULL`) is excluded from the public list and can no longer
        be triggered.

        The public API exposes read access (list + execution history) and the
        manual trigger. Creating, editing and archiving an automation happen in the
        dashboard — there is no POST/PATCH/DELETE `/v1/automations`.
      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`,
            `stage_changed`, `booking_created`, `tag_added`, `manual`, `webhook`.
            Non-exhaustive list — managed in the dashboard.
          example: lead_captured
        action_type:
          type:
            - string
            - 'null'
          description: |
            Main action type the automation runs (e.g. `send_email`,
            `send_sms`, `assign_to_seller`, `create_task`). `null` for
            multi-action automations defined as a node graph.
          example: send_email
        is_active:
          type: boolean
          description: |
            When `false`, the automation is disabled — event triggers
            are ignored and `POST /v1/automations/{id}/trigger` returns
            409 `automation_inactive`.
          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
        (`POST /v1/automations/{id}/trigger`). Creates an execution with `status="pending"`
        and `trigger_type="manual_api"`, ready to be claimed by the automation
        engine. The automation must have `is_active=true` (otherwise
        409 `automation_inactive`), be published (otherwise 409 `automation_not_published`)
        and have a trigger node (otherwise 422 `no_trigger`); the `contact_id`
        must belong to the calling client (otherwise 404 `not_found`).
      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
            the execution and available to the graph's template variables.
            Empty by default.
          example:
            source: crm_import
            campaign: spring-2026
    AutomationTriggerResult:
      type: object
      description: |
        Response of `POST /v1/automations/{id}/trigger`. The API records a
        `pending` execution (claimable by the engine) — processing happens asynchronously
        in the automation engine (no guarantee of synchronous
        completion). To track progress, call
        `GET /v1/automations/{id}/history`.
      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
        (`client_automation_executions`, exposed through
        `GET /v1/automations/{id}/history`). An execution is one run
        of the automation for a given contact. The engine can restart a
        failed execution (`retry_count > 0`).
      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),
            `manual_dashboard` (from the dashboard), or the name of the trigger
            event (`lead_captured`, `stage_changed`, etc.).
          example: manual_api
        status:
          type: string
          description: |
            Execution status. Typical values: `pending`, `running`, `paused`,
            `completed`, `failed`, `cancelled`. The list is managed by the engine — not an enum
            here so it can evolve freely.
          example: completed
        current_node_id:
          type:
            - string
            - 'null'
          description: |
            Identifier of the current node in the execution graph (for
            multi-step automations). `null` for single-action automations.
        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
            the errors encountered. Free-form schema — depends on the automation type.
        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
        in the database: it is a `client_contacts` row with a non-NULL
        `pipeline_stage_id`. You can therefore work with the same resource
        through `/v1/contacts/{id}` AND `/v1/pipeline/deals/{id}` — the deals endpoint
        only filters on and writes the `pipeline_stage_id` field.

        The set of exposed fields is intentionally limited to what matters
        for the pipeline view (contact details + stage + value + score).
        For the full contact (custom_fields, tags, notes), use
        `/v1/contacts/{id}`.

        Available endpoints: `GET /v1/pipeline/deals` (list, filterable by
        `?stage_id`), `POST /v1/pipeline/deals` (places a contact in a stage),
        `PATCH /v1/pipeline/deals/{id}` (moves it to another stage),
        `DELETE /v1/pipeline/deals/{id}` (removes it from the pipeline without deleting the
        contact). **No `GET /v1/pipeline/deals/{id}` is exposed** — retrieve an
        individual deal via `GET /v1/contacts/{id}` or via the
        `?stage_id=...` filter on the list.
      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
            (filter `pipeline_stage_id IS NOT NULL`). A DELETE sets it to NULL,
            and the contact then disappears from the deals list.
        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
        contact in a pipeline stage. **Does not create a contact** — the
        `contact_id` must already exist and belong to the calling account (otherwise
        404 `not_found`). The same applies to `stage_id` (404 `not_found` if the stage
        does not belong to the account).

        If the contact is already in a stage, its `pipeline_stage_id` is overwritten
        with the new value — no movement history is recorded
        (the `pipeline_history` table references the other internal `contacts` table,
        not `client_contacts`).
      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:
        its `pipeline_stage_id` is set to NULL, which removes it from the deals list
        without touching the contact entity (which remains accessible via `/v1/contacts/{id}`).
      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
        stage move is exposed through this endpoint — to change the
        deal's other fields (email, deal_value, etc.), use
        `PATCH /v1/contacts/{id}`.

        `stage_id` is required (400 `validation_error` otherwise). The target stage
        must belong to the account (404 `not_found` otherwise). The target deal must already
        be in the pipeline (404 `not_found` if `pipeline_stage_id IS NULL`).
      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
        stage defines a step in the sales journey (e.g. `New lead`,
        `Qualified`, `Proposal sent`, `Won`, `Lost`). An account can have
        several pipelines: each stage names its own (`pipeline`), and two
        stages from different pipelines can share the same name. Sorted by
        pipeline (in dashboard order), then by `stage_order ASC`.

        The public API is read-only here
        (`GET /v1/pipeline/stages`) — creating, editing, and reordering stages
        is done in the dashboard. The response is not paginated (all of an
        account's stages usually fit under 20 entries) but uses a
        `list V2`-compatible format with `pagination.next_cursor=null`,
        `pagination.has_more=false`, and `pagination.limit` equal to the number of stages
        returned.

        Each stage includes a `contact_count` computed in real
        time — the number of the account's contacts currently in that
        stage, excluding archived and merged contacts, just like the Pipeline screen
        (useful for pipeline KPIs). A stage hidden in the dashboard
        (`is_hidden`) is still listed: its contacts exist and are counted.
      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,
            excluding archived and merged contacts. Computed in real time on every
            call — not cached.
          example: 17
        is_hidden:
          type: boolean
          description: |
            Stage hidden on the dashboard pipeline. Its contacts exist and
            are counted; a tool does not offer it as a destination.
          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
        contact. A thread groups every message exchanged on the same subject
        with the same recipient. Sorted by `last_message_at DESC` (most recent
        first).

        The public API is read-only here
        (`GET /v1/emails/conversations`) — to send an email, use
        `POST /v1/emails/send` (which creates the message and the conversation server-side).
        Reading a conversation's individual messages is not yet
        available through the public API (planned for a later phase —
        today the payload is limited to the thread's metadata).
      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
            hasn't been matched to a `client_contacts` row yet (unknown inbound email
            or not yet reconciled).
        sender_id:
          type: string
          format: uuid
          description: |
            Member of the account's team (`client_users`) who started or owns the
            conversation. Determines the sending mailbox on the Resend side.
        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
            values: `open`, `closed`, `archived`. The list is managed by the platform —
            not an enum here so it can evolve.
          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).
            Used as the thread's primary sort key. NOT NULL in the table.
          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
        is queued with `status="queued"`, `priority=1`, `type="transactional"`
        and delivered by Capturia's email worker (Resend) — the 201 response
        confirms the email was queued, not that it was delivered.

        The sender (`from`) is resolved server-side from the account's default
        sender (`client_email_senders.is_default = true`) — it is not
        accepted in the body. If no default sender is configured:
        400 `no_sender_configured`.

        `Idempotency-Key` is NOT supported on this route — a naive
        retry will create a second send.
      required:
        - to
        - subject
        - body
      additionalProperties: false
      properties:
        to:
          type: string
          format: email
          description: |
            Email recipient. Validated against the standard email pattern
            (`^[^\s@]+@[^\s@]+\.[^\s@]+$`). An invalid format returns
            400 `validation_error`.
          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 —
            the sending worker sanitizes it before
            handing it to Resend for delivery.
          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
            test: personalization variables (`{{first_name}}`,
            etc.) are filled with sample values, the subject is
            prefixed with `[TEST]`, and recipient compliance checks
            (unsubscribe, consent) are bypassed.

            In exchange, two safeguards apply:

            - the recipient (`to`) must be an active member of the account's
              team — otherwise 400 `recipient_not_allowed`;
            - a quota of 50 test sends per rolling 24 hours, shared with
              the platform's test buttons — otherwise 429
              `test_quota_exceeded`.

            A test send creates neither a conversation nor any history on a
            contact.
          example: false
    EmailSendResult:
      type: object
      description: |
        201 response from `POST /v1/emails/send`. The email is queued —
        `status="queued"` on creation. Final delivery (opens, bounces,
        etc.) is tracked separately by the email worker and isn't exposed by
        this endpoint.
      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).
          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).
          example: false
    Webhook:
      type: object
      description: |
        Outbound webhook — an HTTPS endpoint on your side that Capturia calls when a
        subscribed event occurs. A webhook created through the API belongs to both
        the account AND the API key that created it: `GET /v1/webhooks` only shows
        the webhooks of the current key (webhooks created by another key on the same
        account, or from the dashboard, are neither visible nor deletable
        here).

        The HMAC secret used to sign outbound payloads is only
        returned on creation (`POST`) and then encrypted in the database. It can't be
        read again afterward — to rotate it, delete the webhook and
        create a new one.
      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
            delivered to the URL). Disabling happens in the platform — there is no
            public API endpoint to toggle this flag.
          example: true
        created_at:
          $ref: '#/components/schemas/Timestamp'
    WebhookCreate:
      type: object
      description: |
        Body for creating a webhook subscription (`POST /v1/webhooks`). The URL
        must use HTTPS — HTTP is rejected to avoid leaking payloads
        in cleartext. Maximum of 10 active subscriptions per account (beyond that:
        429 `subscription_limit_reached`).
      required:
        - url
        - events
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          description: |
            HTTPS URL that will receive the notifications. Must be a publicly
            reachable endpoint that accepts JSON `POST` requests.
          example: https://hooks.example.com/capturia
        events:
          type: array
          description: |
            Non-empty list of business events to subscribe to, or `*` for all
            events. Each event name is delivered as-is in the
            `X-Capturia-Event` header and the payload's `event` field. Full catalog and
            payload schemas: `docs/webhooks-event-catalog.md`.
          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
        cleartext, returned ONLY ONCE on creation. Store it
        immediately: you use it to verify the signature of outbound payloads
        (`X-Capturia-Signature`), and the API never returns it again.
      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).
                Stored encrypted server-side — it can't be retrieved after this
                response. Used to sign outbound payloads.
              example: 8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3
    PresuasionSubmissionAnswer:
      type: object
      description: |
        Formatted answer to a question from the pre-suasion tool. Used to
        build the human-readable block shown in the contact's timeline in the
        Capturia dashboard (`Q: ...` / `R: ...`).
      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
        (`POST /v1/presuasion-submissions`).

        **Legacy convention — internal integration endpoint that does not follow
        the v1 conventions.** The pre-suasion gateway (`demo.capturia.io`) calls
        this endpoint when a visitor completes a pre-suasion tool form. It does
        not use a Bearer API key (public route, rate-limited by IP), accepts a
        custom body format, and returns a custom body format
        (`{success, contact_id, ...}` instead of `{data: ...}`).

        The target account is resolved server-side through `proposition_slug` →
        `presuasion_instances.client_id` (`client_id` is never accepted from the
        body). The contact is upserted on `(lower(email), client_id)` with
        additive enrichment (existing non-null fields are never overwritten).
      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`).
            Used to resolve the target Capturia account — client_id is never read
            from the body.
          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
            all of its proposals). Informational only: the timeline activity
            identifies the tool by the proposal resolved from `proposition_slug`.
          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
            when `identity_confirmed: true` comes with a valid `token` — the contact
            is then resolved through the tokenized link (rule enforced server-side,
            which JSON Schema cannot express: with neither an email nor an identity
            confirmed by token, the server responds 400).
          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).
            Written additively to the structured `client_contacts.address` column:
            it only fills the column while it is still empty (never overwritten).
          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
            `metadata.responses` of the created activity.
          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
            shown in the contact's timeline.
          items:
            $ref: '#/components/schemas/PresuasionSubmissionAnswer'
        result_data:
          type: object
          additionalProperties: true
          description: |
            Data computed by the pre-suasion tool (e.g. score, recommendation,
            category). Stored as is in `metadata.result_data`.
        completed_at:
          type: string
          format: date-time
          description: |
            Completion timestamp on the gateway side. If missing, the server uses
            `now()`.
          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
            runner (`demo.capturia.io` and unified custom domains). Verified
            server-side through siteverify with an `idempotency_key` equal to the
            body's `submission_id` (stable across retries — a token is single-use,
            and Cloudflare replays the original verdict for the same key), falling
            back to the request's `request_id` when `submission_id` is missing.
            Optional in log-only mode; required when
            `PRESUASION_TURNSTILE_REQUIRED=true` (403 response otherwise).
        submission_id:
          type: string
          format: uuid
          description: |
            Idempotency key generated by the gateway, stable across retries of the
            same submission. If a `presuasion_submission` activity already exists
            with this `submission_id` for the resolved account, the server returns
            the existing activity — before any quota count and any write — instead
            of creating a second one (prevents duplicates on a transient retry, e.g.
            a proxy timeout after a server-side success). Uniqueness is guaranteed
            in the database by a partial unique index: two concurrent submissions
            with the same `submission_id` produce a single activity.
          example: b3f1c2a4-5d6e-4f70-8a91-2c3d4e5f6071
        token:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Token of the personalized pre-suasion link (`presuasion_link_tokens`),
            passed by the gateway when the visitor arrives through a link sent to a
            known contact. Used for token-first attribution of the contact and for
            the link's lifecycle. Never used after an `identity_declined`.
          example: tok_abcdefabcdefabcdefabcdef
        session_key:
          type: string
          format: uuid
          description: |
            Anonymous key of the visit (`presuasion_tool_sessions.session_key`),
            generated by the runner. Ties the submission to its visit: replay of
            the identity denial, session completion, and a `metadata.session_id`
            trace on the activity (funnel analytics cohort).
            Missing = submission without visit tracking ("unlinked" lead).
          example: 6f1f5e0a-2b3c-4d5e-8f9a-0b1c2d3e4f5a
        identity_confirmed:
          type: boolean
          description: |
            The visitor confirmed being the recipient of the tokenized link
            ("That's me"). With `token`, the contact is resolved through the link
            and `email` becomes optional. Mutually exclusive with
            `identity_declined` (400 otherwise).
          example: true
        identity_declined:
          type: boolean
          description: |
            The visitor stated they are NOT the recipient of the link
            ("That's not me"). The token never attributes the contact, the denial
            is replayed server-side before completion, and `email` becomes required
            again. Mutually exclusive with `identity_confirmed` (400 otherwise).
          example: false
        custom_domain_id:
          type: string
          description: |
            Custom domain identifier (`client_domains.id`) resolved server-side by
            the gateway proxy from the submission's host. Used ONLY to disambiguate
            the proposal when the same active slug exists in several domain buckets.
            Missing = shared bucket (demo.capturia.io,
            `custom_domain_id IS NULL`). Never used to resolve client_id (always
            derived from the proposal on the server side).
          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
            capture time. Governs the `Lead` event sent to the account's Meta pixel
            through the Conversions API: on a surface with a banner, only `granted`
            allows sending; `denied` is persisted on the contact and blocks any
            later sending; missing = no choice expressed, nothing is sent.
          example: granted
        source_url:
          type: string
          format: uri
          maxLength: 2048
          description: |
            URL of the tool page at capture time (Meta `event_source_url`). When
            `utm` and `gclid` are missing, its `utm_*` parameters and its Google Ads
            click identifier serve as a fallback for ad attribution. Best effort: a
            value that is not an absolute http(s) URL is ignored (and logged), never
            rejected — a measurement field never makes the submission fail.
          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
            `ads_consent` is `granted`.
          example: fb.1.1700000000000.1234567890
        fbc:
          type: string
          maxLength: 500
          description: |
            Meta `_fbc` click cookie (or derived from `fbclid`), passed to Meta as
            is — kept only when `ads_consent` is `granted`.
          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
            this route is then the proxy's). Used for Meta matching only. Honored
            only when the request carries the proxy's HMAC signature; without a
            signature, the request's own IP prevails and this value is ignored.
          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
            matching only. Same rule as `visitor_ip`: honored only under an HMAC
            signature; otherwise the request's User-Agent header prevails.
          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).
            They attach the lead to the campaign whose web source matches
            (per-campaign report, campaign tag), on first touch: a contact that is
            already attributed never changes. Kept only for a submission that went
            through the tool (signed by its proxy or with a valid Turnstile token),
            never for the contact of a personal link (`token`). An unreadable field
            is dropped, never rejected.
          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
            counts as a google / cpc source. Same rules as `utm`; dropped if
            unreadable. When `ads_consent` is `denied`, the identifier is never
            stored: only the google / cpc source remains.
          example: Cj0KCQjw5ZSWBhCVARIsALERCvzZ
        captured_at:
          type: string
          format: date-time
          description: |
            Time of the first visit. Dates the ad attribution; ignored if it is
            unreadable or in the future (the submission date then prevails).
          example: '2026-09-20T14:00:00.000Z'
    PresuasionSubmissionResponse:
      type: object
      description: |
        Response of `POST /v1/presuasion-submissions` (200 OK).

        **Legacy convention** — custom body that does not follow the v1
        `{data: ...}` convention. Top-level `success` boolean plus scalar
        fields. `contact_created` indicates whether the contact was created
        (`true`) or only enriched (`false`).
      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
            was enriched (missing fields were filled in, fields already present
            were never overwritten).
          example: false
        activity_id:
          type: string
          format: uuid
          description: |
            Identifier of the `presuasion_submission` activity created in the
            contact's timeline.
    PresuasionSubmissionError:
      type: object
      description: |
        Error body of the 4xx/5xx responses of `POST /v1/presuasion-submissions`.

        **Legacy convention** — custom body that does not follow the v1
        catalog's `ErrorEnvelope`. `error` is a human-readable string (not a
        `{code, message, ...}` object), and `issues` is present only for Zod
        validation errors (400 `Invalid payload`).
      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,
        or is still being processed.
      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).
        Clients should retry with exponential backoff.
      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
        starts with `campaign:`): it follows the lead's campaign and can't be attached
        or detached manually.
      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
        disqualification is lifted. Moving it to a lost stage, or to
        the disqualification stage, is still accepted.
      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
        returns the original response without repeating the operation. Stored for 24h. Optional but recommended.
    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.
