openapi: 3.1.0
info:
  title: Capturia API
  version: v1
  summary: API publique Capturia pour intégrations partenaires et clients
  description: |
    L'API Capturia permet d'intégrer Capturia avec des outils tiers (Make, Zapier,
    code custom, CRMs externes) via des endpoints REST authentifiés par clé API.

    ## Démarrage rapide

    1. Créer une clé API dans le dashboard : `Admin → Intégrations → Clés API`
    2. Choisir le preset adapté (`Capture de leads` pour pousser des leads depuis un funnel)
    3. Tester avec `GET /v1/me` pour vérifier que la clé fonctionne
    4. Capturer un lead : `POST /v1/leads/capture`

    Documentation complète : https://capturia.io/fr/developers
  contact:
    name: Capturia Support
    url: https://capturia.io/fr/developers/support
    email: support@capturia.io
  license:
    name: Proprietary
    url: https://capturia.io/fr/legal/terms
  termsOfService: https://capturia.io/fr/legal/terms
servers:
  - url: https://api.capturia.io
    description: Production (recommandé)
  - url: https://app.capturia.io/api
    description: Production (legacy, sunset 2027-05-03)
security:
  - ApiKeyAuth: []
tags:
  - name: Me
    description: Endpoint whoami pour vérifier qu'une clé API fonctionne
  - name: Usage
    description: Métriques de consommation programmatiques
  - name: Leads
    description: Capture de leads depuis funnels externes (Make, Zapier, code custom)
  - name: Contacts
    description: CRUD complet sur les contacts (prospects + clients)
  - name: Conversations
    description: Threads de discussion (chat, SMS, appel, email) unifiés
  - name: Bookings
    description: Rendez-vous calendrier
  - name: Quotes
    description: Devis et propositions commerciales
  - name: Tags
    description: Étiquettes pour catégoriser les contacts
  - name: Automations
    description: Workflows automatisés (relances, séquences, déclencheurs)
  - name: Pipeline
    description: Deals et stages du pipeline de vente
  - name: Emails
    description: Envoi et lecture d'emails transactionnels
  - name: Webhooks
    description: Endpoints sortants pour notifier des évènements à un système externe
  - name: Presuasion submissions
    description: Soumissions de formulaires d'outils pré-suasion
  - name: Events
    description: Ingestion d'événements personnalisés qui déclenchent les automatisations
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: Vérifier qu'une clé API fonctionne
      description: |
        Endpoint whoami minimal — retourne les infos de la clé qui authentifie la requête
        (nom, scopes, preset inféré, expiration, rate limits, client). C'est l'outil le plus
        simple pour confirmer qu'une intégration tierce est connectée. Aucune mutation,
        aucun side-effect — sécurisé à appeler depuis n'importe quel script de smoke test.

        Aucun scope spécifique n'est requis ; toute clé authentifiée et active peut appeler
        cet endpoint. Consomme 1 unité du rate limit per-key (pour éviter qu'un script en
        boucle ne sature le tracking).
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Clé valide.
          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: Lire l'usage agrégé de la clé API authentifiée
      description: |
        Retourne les buckets d'agrégation (par heure ou par jour) de la consommation de
        la clé authentifiée sur une plage temporelle donnée, plus les totals calculés
        application-side. Utile pour dimensionner sa consommation, monitorer les latences
        et détecter les rejets rate limit.

        Aucun scope spécifique requis (pattern GitHub `/rate_limit`) : consulter ses
        propres metrics est gratuit et toujours permis pour toute clé authentifiée.

        La plage `(to - from)` doit être strictement positive et ne pas dépasser
        **90 jours**. Les buckets retournés sont triés par `bucket_start ASC` ; les
        fenêtres sans trafic sont absentes du tableau (pas de zéro-padding).
      security:
        - ApiKeyAuth: []
      parameters:
        - name: from
          in: query
          required: true
          description: |
            Borne inférieure (inclusive) de la plage temporelle, ISO 8601 UTC.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: to
          in: query
          required: true
          description: |
            Borne supérieure (exclusive) de la plage temporelle, ISO 8601 UTC. Doit
            être strictement après `from` et la plage `(to - from)` ≤ 90 jours.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: granularity
          in: query
          required: false
          description: |
            Granularité des buckets retournés. `day` par défaut.
          schema:
            type: string
            enum:
              - hour
              - day
            default: day
      responses:
        '200':
          description: Buckets et totals d'usage pour la plage demandée.
          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: 1234
                      status_2xx: 1200
                      status_4xx: 30
                      status_5xx: 4
                      rate_limited_count: 5
                      latency_p50_ms: 42.5
                      latency_p95_ms: 180
                      latency_p99_ms: 350
                  totals:
                    request_count: 1234
                    status_2xx: 1200
                    status_4xx: 30
                    status_5xx: 4
                    rate_limited_count: 5
        '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: Capturer un lead depuis un funnel externe
      description: |
        Capture un nouveau lead dans Capturia depuis un funnel, formulaire ou outil
        tiers (Make, Zapier, n8n, landing custom). Une fois capturé, le lead apparaît
        immédiatement dans **Mes Prospects** côté dashboard et le bot SMS prend le
        relais dans les 30 secondes si le consentement et la configuration sont en
        ordre.

        ### Contact déjà connu (courriel ou téléphone)
        La fiche est résolue par **courriel d'abord** (insensible à la casse),
        puis par **téléphone**, en incluant les coordonnées secondaires déjà
        rattachées à une fiche. Capturer un lead dont le courriel ou le
        téléphone existe déjà **réutilise et enrichit la fiche existante** — le
        `contact_id` retourné est celui de cette fiche, jamais un doublon et
        jamais une erreur. Les coordonnées et champs cœur déjà remplis (nom,
        courriel, téléphone, pipeline, valeur, adresse...) ne sont pas
        écrasés ; les réponses de qualification (`custom_fields`) sont en
        revanche rafraîchies avec les dernières valeurs envoyées — une clé
        déjà présente est remplacée, une clé nouvelle s'ajoute. Un nouveau
        téléphone est conservé comme numéro secondaire quand la fiche en a
        déjà un. Si le courriel et le téléphone pointent deux fiches
        différentes, le courriel a priorité et aucune coordonnée n'est retirée
        à l'autre fiche.

        La réponse expose `contact_was_new` pour distinguer les deux issues :
        sur une fiche existante (`false`), `pipeline` et `assignment_status`
        décrivent la demande résolue, pas forcément l'état persisté — la fiche
        conserve son pipeline, son étape et son vendeur déjà en place.

        ### Contact déjà en pipeline ouvert
        Un lead dont le contact (résolu par courriel ou téléphone) est déjà dans
        une étape de pipeline ouverte (déjà pris en charge par un vendeur — non
        archivé, étape ni gagnée ni perdue)
        n'est pas re-soumis : la réponse renvoie `status: existing_pipeline_contact`,
        aucune nouvelle soumission n'est enregistrée et le bot SMS n'est pas
        redéclenché, pour ne pas écraser une conversation en cours. Les champs
        cœur nouvellement fournis enrichissent quand même le contact existant.

        La dédup de retry reste prioritaire : un re-push reçu dans la fenêtre de
        5 minutes est traité comme un retry (`status: idempotent_replay`, voir
        Idempotency) et conserve l'`activity_id` et l'`original_created_at`
        d'origine — `existing_pipeline_contact` ne s'applique qu'au-delà de cette
        fenêtre.

        ### Idempotency
        Le header `Idempotency-Key` est **optionnel mais recommandé** pour les retries
        côté client. Une 2e requête avec la même clé et le même payload renvoie la
        réponse d'origine sans dupliquer le lead. La clé est stockée 24h. Une 2e
        requête avec la même clé mais un payload différent retourne 409
        `idempotency_conflict`. La RPC sous-jacente conserve par ailleurs sa propre
        fenêtre de dedup à 5 minutes. Cette fenêtre est **par contact et
        indépendante du contenu** : toute soumission d'un contact déjà capturé
        dans les 5 dernières minutes est traitée comme un retry
        (`idempotent_replay`) même si le payload diffère — la fiche est tout de
        même enrichie (dont les `custom_fields`, rafraîchis), mais aucune
        nouvelle activité, aucun tag, et pas de relance du bot SMS. Les mises à
        jour de la fiche restent réelles : une automatisation qui surveille un
        champ personnalisé réagit au changement de valeur, comme pour toute
        modification de la fiche. C'est aussi ce qui protège d'une double
        relance du bot quand un lead re-soumet son formulaire coup sur coup.
        Un `Idempotency-Key` différent ne contourne pas cette fenêtre : elle
        s'applique en base après tout cache miss. Deux soumissions
        volontairement distinctes du même contact ne produisent deux activités
        que si elles sont espacées de plus de 5 minutes.

        ### Codes d'erreur custom
        Pour des raisons historiques, certains rejets retournent des codes hors
        catalog standard :

        - **422** — `invalid_phone`, `missing_consent`, `invalid_email`,
          `invalid_payload`, `pipeline_not_found`, `stage_not_found` (au lieu de
          `validation_error` / `business_rule_violation`). Body shape simplifiée
          propre à cette route — voir la réponse 422 ci-dessous.
        - **429** — `rate_limited_ip` quand la limite IP (100 req/min) est atteinte
          (au lieu de `rate_limit_exceeded`). Le header `X-RateLimit-Scope: ip`
          identifie le scope de la limite déclenchée. Body shape simplifiée (pas de
          `request_id` ni `details` standard) — à harmoniser dans une phase
          ultérieure.
      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 capturé (`status: created`), replay idempotent d'une capture
            précédente (`status: idempotent_replay`), ou contact déjà pris en
            charge (`status: existing_pipeline_contact` — déjà dans une étape de
            pipeline ouverte : aucune nouvelle soumission n'est enregistrée et le
            bot SMS n'est pas déclenché, `activity_id` est `null`). Dans tous les
            cas, le `contact_id` retourné est 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
                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':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          description: |
            Payload refusé par la validation (`invalid_phone`, `missing_consent`,
            `invalid_email`, `invalid_payload`) ou par la résolution du pipeline
            (`pipeline_not_found`, `stage_not_found`). Forme simplifiée propre à
            cette route : pas de `type` ni de `doc_url`, et `details` est un
            tableau `[{ field, reason }]` (`field` vide quand le body n'est pas
            un objet).
            Pour un champ scalaire fautif, le `message` renvoie en écho la
            valeur reçue (bornée à 120 caractères) — elle n'est jamais conservée
            côté Capturia ; seuls les noms des champs reçus le sont, visibles
            dans l'onglet Erreurs récentes de la clé API. Le `request_id` est
            toujours dans l'en-tête `X-Request-ID`.
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestID'
          content:
            application/json:
              schema:
                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: |
                          Explication du refus. Pour un champ scalaire fautif,
                          préfixée de `Received <valeur> for field "<champ>" —`.
                      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: |
                                Chemin du champ fautif (`phone`,
                                `pipeline.stage_slug`, `custom_fields.ma_cle`).
                            reason:
                              type: string
              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: Pousser un événement personnalisé qui déclenche les automatisations
      description: |
        Webhook entrant générique par compte : une source externe (Zapier, Make,
        n8n, un backend client, un outil tiers) pousse un événement nommé avec
        ses données. Capturia résout le contact (`external_id` → `phone` →
        `email`), enrichit sa fiche de façon non-destructive, remplit les champs
        personnalisés mappés, enregistre l'événement sur la fiche, puis démarre
        les automatisations dont le déclencheur « Événement personnalisé »
        écoute ce nom d'événement — avec le `payload` disponible dans les
        messages via `{{event.<clé>}}`.

        ### Résolution du contact
        Au moins un identifiant est requis (`external_id`, `email` ou `phone`).
        Si aucun contact ne matche, une fiche minimale est créée — sauf si
        `contact.create_if_missing` est `false`, auquel cas la requête répond
        `404 contact_not_found` sans rien enregistrer. Si les identifiants ne
        matchent qu'une fiche **archivée**, l'événement est enregistré dessus
        (traçabilité) mais la fiche n'est pas modifiée et aucune automatisation
        n'est enrôlée (`contact_archived: true`).

        ### Consentement (Loi 25 / LCAP)
        La réception d'un événement n'établit **aucun** consentement de
        communication. Un contact créé par ce chemin ne peut pas recevoir de
        SMS/courriel tant qu'un consentement n'est pas capté par un autre flux ;
        une automatisation déclenchée qui tenterait un envoi sans consentement
        est bloquée par les gardes du moteur d'exécution.

        ### Signature HMAC optionnelle
        En plus de la clé API, la requête peut porter une signature
        `X-Capturia-Signature: t=<unix>,v1=<hex>` — HMAC-SHA256 de
        `${t}.${corps brut}` calculée avec **la clé API brute comme secret**,
        fenêtre anti-replay de 5 minutes. Dès que le header est présent, la
        validation est stricte (fail-closed) : signature invalide = `401
        invalid_signature`. Sans le header, la clé API seule authentifie.

        ### Idempotency
        Le header `Idempotency-Key` est optionnel mais recommandé pour les
        retries : une 2e requête avec la même clé et le même payload renvoie la
        réponse d'origine sans ré-ingérer l'événement (stockage 24h ; payload
        différent = `409 idempotency_conflict`). Par ailleurs, l'enrôlement des
        automatisations porte sa propre dédup de 5 minutes par contact et par
        automatisation.

        ### Codes d'erreur custom
        - **429** — `rate_limited_ip` quand la limite IP (120 req/min) est
          atteinte, header `X-RateLimit-Scope: ip`. Limite par clé : 60 req/min
          (modulable par clé).
      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: |
            Événement ingéré (`status: ingested`). `enrolled_executions` indique
            combien d'automatisations ont démarré ; `0` signifie qu'aucune
            automatisation active n'écoute ce nom d'événement — l'événement est
            quand même enregistré sur la fiche 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/contacts:
    get:
      tags:
        - Contacts
      operationId: listContacts
      summary: Lister les contacts
      description: |
        Retourne la liste paginée des contacts du client (cursor v2).
        Tri stable `created_at DESC, id DESC`. Supporte recherche
        case-insensitive sur `email`, `first_name`, `last_name`, `company`,
        filtre par tag, et inclusion conditionnelle des définitions de
        custom fields dans `meta`.

        ### Recherche

        Le paramètre `?search` est sanitizé côté serveur — les caractères
        `%`, `_`, `,`, `(`, `)`, `.`, `*`, `\` sont retirés avant l'application
        du filtre `ilike` pour empêcher l'injection PostgREST.

        ### Inclusion custom fields

        Passer `?include=custom_field_definitions` ajoute le champ
        `meta.custom_field_definitions` à la réponse — utile pour rendre
        un formulaire d'édition côté intégrateur sans 2e 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: Recherche dans email, first_name, last_name, company (case-insensitive, sanitizée).
        - name: tag
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: UUID d'un tag — filtre les contacts qui ont ce tag.
        - name: include
          in: query
          required: false
          schema:
            type: string
            enum:
              - custom_field_definitions
          description: Inclut le bloc `meta.custom_field_definitions` dans la réponse.
      responses:
        '200':
          description: Liste paginée des 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: Bloc additionnel renvoyé seulement si `?include=custom_field_definitions`.
                    properties:
                      custom_field_definitions:
                        type: array
                        description: Définitions des champs personnalisés du client (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: Créer un contact
      description: |
        Crée un contact sous le client de la clé. Email obligatoire et
        dédupliqué — si un contact avec ce mail existe déjà sous ce client,
        la réponse est `409 duplicate_resource` (le contact existant n'est
        pas écrasé).

        ### Idempotency

        Le header `Idempotency-Key` est **optionnel mais recommandé**.
        Une 2e requête avec la même clé et le même payload renvoie la
        réponse d'origine sans dupliquer l'opération. Une 2e requête avec
        la même clé et un payload différent retourne `409 idempotency_conflict`.

        ### Custom fields

        Les clés de `custom_fields` sont le **nom** du champ tel que défini
        dans Capturia (ou, de façon équivalente, l'id de sa définition) —
        même contrat que `/v1/events` et `/v1/leads/capture`. Les valeurs
        sont validées contre les définitions du client
        (`client_custom_field_definitions`). Un champ inconnu ou un type
        incompatible retourne `400 invalid_custom_fields` avec `details`
        listant les 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 créé.
          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: null
                  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: Récupérer un contact
      description: |
        Retourne un contact par son ID. Le paramètre `?expand=tags` joint la
        table `client_contact_tags` pour inclure les tags rattachés (id, nom,
        couleur) sans round-trip supplémentaire.
      security:
        - ApiKeyAuth:
            - contacts:read
      parameters:
        - name: expand
          in: query
          required: false
          schema:
            type: string
            enum:
              - tags
          description: Si `tags`, ajoute `client_contact_tags` au contact retourné.
      responses:
        '200':
          description: Contact trouvé.
          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, enrichi de ses tags si `?expand=tags` est passé.
                      `ContactWithTags` étend `Contact` via `allOf` — un payload
                      sans tags satisfait les deux schémas (d'où `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: Mettre à jour un contact
      description: |
        Met à jour les champs fournis du contact (champs absents conservés).
        Au moins un champ doit être fourni sinon `400 no_fields`.

        Le champ `custom_fields` est **mergé** avec les valeurs existantes
        (les clés non fournies sont conservées). Les clés sont le **nom** du
        champ (ou l'id de sa définition) ; une clé inconnue retourne
        `400 invalid_custom_fields`. Passer `null` efface tous les champs
        personnalisés.
      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 mis à jour.
          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'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      tags:
        - Contacts
      operationId: deleteContact
      summary: Supprimer un contact
      description: |
        Supprime définitivement le contact, exactement comme depuis le tableau
        de bord : sa conversation d'inbox, ses échanges (SMS, courriels,
        transcriptions de chat), ses rendez-vous et ses fichiers partent avec
        lui. Les ventes, commandes et paiements sont conservés, simplement
        détachés de la fiche.

        Le retrait hors plateforme (rendez-vous au calendrier Google connecté,
        fichiers stockés) est fait dans la foulée, en best-effort : la
        suppression du contact en base réussit même si un service externe est
        momentanément injoignable. L'objet `cleanup` de la réponse l'indique —
        `failed` > 0 signifie que des rendez-vous Google n'ont pas pu être
        retirés du calendrier (compte déconnecté, panne) et y restent.
      security:
        - ApiKeyAuth:
            - contacts:write
      responses:
        '200':
          description: Contact supprimé.
          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 du contact supprimé.
                      deleted:
                        type: boolean
                        description: Toujours `true` quand le 200 est renvoyé.
                      cleanup:
                        type: object
                        description: Résultat du retrait des rendez-vous au calendrier Google connecté (best-effort). `failed` > 0 = des rendez-vous n'ont pas pu être retirés et restent au calendrier.
                        required:
                          - attempted
                          - succeeded
                          - failed
                        properties:
                          attempted:
                            type: integer
                            description: Rendez-vous Google visés par le retrait.
                          succeeded:
                            type: integer
                            description: Rendez-vous retirés du calendrier.
                          failed:
                            type: integer
                            description: Rendez-vous restés au calendrier après échec.
              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: Timeline d'un contact (activités chronologiques)
      description: |
        Retourne la timeline d'activités du contact (changements de stage,
        envois SMS/email, RDV, etc.) en ordre chronologique inverse.

        ### Limitation actuelle

        L'implémentation est un stub : la table `pipeline_history` référence
        l'autre table `contacts` interne (et non `client_contacts`), donc
        la liste retournée est **toujours vide** aujourd'hui. La pagination
        est validée mais non appliquée. À implémenter dans une phase
        ultérieure quand l'historique sera unifié sur `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: Liste vide (implémentation stub) avec pagination valide.
          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: Toujours vide aujourd'hui (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: Attacher un ou plusieurs tags à un contact
      description: |
        Attache un ou plusieurs tags au contact via la table de jointure
        `client_contact_tags`. Les tags doivent appartenir au même client
        que le contact — sinon `400 validation_error` avec la liste des
        tags non trouvés.

        L'opération est idempotente côté DB : appeler 2 fois avec les
        mêmes `tag_ids` ne crée pas de doublons (upsert
        `ON CONFLICT (contact_id, tag_id) DO NOTHING`).

        ### Lister les tags d'un contact

        Il n'existe pas de `GET` dédié — utiliser
        `GET /v1/contacts/{id}?expand=tags` pour récupérer les tags
        actuellement rattachés.
      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
                  description: UUIDs des tags à attacher (tous doivent appartenir au même client).
                  items:
                    type: string
                    format: uuid
            example:
              tag_ids:
                - 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
                - 9a4c2b3d-0e5f-5a6b-c7d8-e9f0a1b2c3d4
      responses:
        '200':
          description: Tags attachés. Retourne la liste à jour des tags du contact.
          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: Liste à jour des tags du contact (tous, pas seulement les nouveaux).
                        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: Couleur hexadécimale du tag (préfixe `#`).
                                  example: '#ef4444'
              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'
        '400':
          $ref: '#/components/responses/ValidationError'
        '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/{tagId}:
    parameters:
      - $ref: '#/components/parameters/ResourceIdParam'
      - name: tagId
        in: path
        required: true
        schema:
          type: string
          format: uuid
        description: UUID du tag à détacher.
    delete:
      tags:
        - Contacts
      operationId: detachContactTag
      summary: Détacher un tag d'un contact
      description: |
        Retire l'association entre un contact et un tag. Le tag lui-même
        n'est pas supprimé — il reste disponible pour d'autres contacts.

        Si le tag n'est pas attaché au contact (ou si le contact appartient
        à un autre client), retourne `404 resource_not_found`.
      security:
        - ApiKeyAuth:
            - contacts:write
      responses:
        '200':
          description: Tag détaché.
          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: Toujours `true` quand le 200 est renvoyé.
              example:
                data:
                  contact_id: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
                  tag_id: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
                  removed: true
        '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/batch:
    post:
      tags:
        - Contacts
      operationId: createContactsBatch
      summary: Créer ou mettre à jour plusieurs contacts en une requête
      description: |
        Upsert atomique de jusqu'à 100 contacts en une seule requête. Les
        contacts dont l'email existe déjà sous le client sont **mis à jour**
        (les autres champs fournis remplacent les valeurs existantes), les
        autres sont **insérés**.

        ### Déduplication interne

        Le payload est dédupliqué en interne par `lower(email)` (last-entry
        wins) avant l'upsert. L'ordre des contacts dans le tableau et la
        casse de l'email n'affectent pas le hash idempotency.

        ### Idempotency

        Le header `Idempotency-Key` est **optionnel mais recommandé**. Le
        hash idempotency porte sur le payload **après dédup interne** —
        retry réseau idempotent même si le client renvoie les contacts dans
        un ordre différent ou avec une casse email différente.

        ### Réponse

        L'endpoint retourne `200` avec `{data: {processed, contacts: []}}`
        — pas de multi-status par contact. Une erreur de validation sur un
        seul contact rejette l'ensemble du batch (`400 validation_error`
        avec `details` listant chaque ligne fautive). Une erreur DB en
        cours d'upsert retourne `500` — les contacts déjà persistés
        avant l'erreur restent (pas de rollback transactionnel
        cross-statements).

        Quand `system_id` est fourni et résolu vers un Système actif, la
        réponse inclut aussi `system_routing` : le bilan du routage des
        contacts **créés** vers ce Système. Le routage est best-effort
        (l'upsert est déjà commité quand il tourne) — `refused + errors` =
        contacts créés restés dans le Système principal.
      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: Liste de contacts à upserter (max 100 par requête, sinon `400 batch_too_large`).
                  items:
                    $ref: '#/components/schemas/ContactCreate'
                system_id:
                  type: string
                  format: uuid
                  description: |
                    Système cible du lot (UUID d'un Système du compte). Les
                    contacts **créés** par ce batch sont engagés dans ce
                    Système ; les contacts mis à jour gardent leur engagement.
                    Absent → Système principal (comportement historique). UUID
                    inconnu pour ce compte → `system_not_found` (422), aucun
                    contact écrit ; Système archivé → repli silencieux sur le
                    Système principal.
                  example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
                trigger_automations:
                  type: boolean
                  default: false
                  description: |
                    Déclenche les automatisations et séquences actives du
                    compte pour les contacts de ce lot (contact créé, tags
                    ajoutés). **Défaut `false` : rien ne se déclenche** —
                    aucun enrôlement d'automatisation ni de séquence, aucun
                    courriel ni SMS causé par cet import. À `true`, la réponse
                    remonte `automations_enrolled` et, si le plafond horaire
                    du moteur (200 enrôlements/compte/heure) est atteint,
                    `automations_skipped_rate_limit` — jamais d'abandon
                    silencieux.
            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 traité. `processed` = nombre de contacts insérés ou mis à jour avec succès.
          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: Nombre total de contacts traités (insérés + mis à jour).
                      automations_enrolled:
                        type: integer
                        minimum: 0
                        description: |
                          Enrôlements d'automatisations créés par ce lot.
                          Toujours `0` quand `trigger_automations` est absent
                          ou `false`.
                      automations_skipped_rate_limit:
                        type: integer
                        minimum: 0
                        description: |
                          Enrôlements refusés parce que le plafond horaire du
                          moteur d'automatisations (200 enrôlements/compte/
                          heure) était atteint. `> 0` = une partie du lot n'est
                          pas entrée dans les automatisations — ré-importer ces
                          contacts plus tard ou les enrôler manuellement.
                      sequences_enrolled:
                        type: integer
                        minimum: 0
                        description: |
                          Enrôlements de séquences courriel créés par ce lot.
                          Toujours `0` quand `trigger_automations` est absent
                          ou `false`.
                      contacts:
                        type: array
                        description: Lignes contact persistées (subset des champs principaux, sans custom_fields ni source).
                        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'
                            created_at:
                              $ref: '#/components/schemas/Timestamp'
                            updated_at:
                              $ref: '#/components/schemas/Timestamp'
                      system_routing:
                        type: object
                        description: |
                          Bilan du routage des contacts **créés** vers le
                          Système cible. Présent seulement quand `system_id` a
                          été fourni et résolu vers un Système actif. Le
                          routage est best-effort : `refused + errors` =
                          contacts créés restés dans le Système principal.
                        required:
                          - requested
                          - routed
                          - noop
                          - refused
                          - errors
                        properties:
                          requested:
                            type: integer
                            minimum: 0
                            description: Contacts créés soumis au routage.
                          routed:
                            type: integer
                            minimum: 0
                            description: Contacts engagés dans le Système cible.
                          noop:
                            type: integer
                            minimum: 0
                            description: Contacts déjà engagés dans le Système cible — rien à faire.
                          refused:
                            type: integer
                            minimum: 0
                            description: Refusés par le directeur de trafic (RDV à venir, proposition ouverte…) — restés dans le Système principal.
                          errors:
                            type: integer
                            minimum: 0
                            description: Échecs techniques (contact en erreur ou appel de routage échoué) — restés dans le Système principal.
              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
                      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
                      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: Lister les conversations
      description: |
        Retourne la liste paginée des conversations (threads de chat) du
        client (cursor v2). Tri stable `created_at DESC, id DESC`.

        Une conversation agrège les messages d'un visiteur avec un agent
        (IA ou humain) sous le client appelant. La création de threads et
        l'envoi de messages se font côté front-end / SDK chat — l'API
        publique expose la lecture seule.

        ### Filtres

        - `?agent_slug` — filtre exact sur l'agent IA répondant dans le thread.
        - `?source` — filtre exact sur la source du thread (URL, label de
          funnel, `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: Slug de l'agent IA — filtre exact sur les threads servis par cet agent.
        - name: source
          in: query
          required: false
          schema:
            type: string
            maxLength: 200
          description: Source du thread — filtre exact (URL, label de funnel, `widget`, etc.).
      responses:
        '200':
          description: Liste paginée des 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
                    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: Récupérer une conversation
      description: |
        Retourne les méta-données d'une conversation par son ID. Pour
        récupérer les messages individuels, utiliser
        `GET /v1/conversations/{id}/messages`.

        Si la conversation appartient à un autre client (ou n'existe pas),
        retourne `404 resource_not_found` — l'isolation tenant masque
        l'existence des conversations hors du client appelant.
      security:
        - ApiKeyAuth:
            - conversations:read
      responses:
        '200':
          description: Conversation trouvée.
          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: Lister les messages d'une conversation
      description: |
        Retourne la liste paginée des messages d'une conversation en
        **ordre chronologique de lecture** (`created_at ASC, id ASC`),
        avec un tiebreaker `id` pour éviter les doublons ou skips entre
        deux pages quand plusieurs messages partagent la même milliseconde.

        Cette direction de tri (ASC) est inversée par rapport aux autres
        list endpoints v2 (DESC) — la lecture naturelle d'une conversation
        suit l'ordre chronologique des échanges.

        Si la conversation appartient à un autre client (ou n'existe pas),
        retourne `404 resource_not_found`.
      security:
        - ApiKeyAuth:
            - conversations:read
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Liste paginée des messages (ordre chronologique).
          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'
                  - 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'
                pagination:
                  next_cursor: null
                  has_more: false
                  limit: 20
                  cursor: null
                  total_count: 2
        '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: Lister les rendez-vous
      description: |
        Retourne la liste paginée des rendez-vous (cursor v2). Tri stable
        `start_datetime DESC, id DESC` — les rendez-vous à venir apparaissent
        d'abord, puis l'historique.

        L'isolation tenant est appliquée via une résolution préalable des
        projets autorisés : si le client n'a aucun projet, la liste retournée
        est vide (pas d'erreur).

        ### Filtres

        - `?from` (ISO 8601) — `start_datetime >= from`
        - `?to` (ISO 8601) — `start_datetime <= to`
        - `?status` — filtre exact (`confirmed`, `cancelled`, etc.)
        - `?project_id` — filtre exact ; un `project_id` qui n'appartient pas
          au client retourne une liste vide (pas une erreur).
      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: Filtre — uniquement les rendez-vous dont `start_datetime >= from`.
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Filtre — uniquement les rendez-vous dont `start_datetime <= to`.
        - name: status
          in: query
          required: false
          schema:
            type: string
            maxLength: 50
          description: Filtre exact sur le statut (`confirmed`, `cancelled`, etc.).
        - name: project_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Filtre par projet client. Un `project_id` hors du client retourne une liste vide.
      responses:
        '200':
          description: Liste paginée des rendez-vous.
          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: null
                    project_id: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
                    guest_name: Alex Tremblay
                    guest_email: alex@example.com
                    guest_phone: '+15145551234'
                    start_datetime: '2026-05-10T14:00:00.000Z'
                    end_datetime: '2026-05-10T15:00:00.000Z'
                    timezone: America/Montreal
                    status: confirmed
                    source: api
                    notes: null
                    meeting_link: null
                    video_provider: null
                    cancelled_at: null
                    cancel_reason: null
                    assigned_to_id: null
                    zoom_join_url: 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:
        - Bookings
      operationId: createBooking
      summary: Créer un rendez-vous
      description: |
        Crée un rendez-vous dans le projet ciblé. Le `project_id` doit
        appartenir au client appelant — sinon `404 resource_not_found`.
        S'il est fourni, `event_type_id` doit référencer un type d'événement
        actif du catalogue Capturia — sinon `404 resource_not_found`, sans
        création.

        Les champs `status` et `source` ne sont pas acceptés du caller :
        forcés à `confirmed` / `api` côté serveur. Pour annuler un
        rendez-vous, utiliser `DELETE /v1/bookings/{id}` (soft cancel).

        ### Format des dates

        Aucune validation côté serveur du format ISO 8601 sur
        `start_datetime` / `end_datetime` — les valeurs sont transmises
        telles quelles à Postgres qui rejettera un format invalide
        (`500 internal_error`). Préférer ISO 8601 UTC (`...Z`) pour éviter
        les ambiguïtés de fuseau.
      security:
        - ApiKeyAuth:
            - bookings:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookingCreate'
            example:
              project_id: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
              guest_name: Alex Tremblay
              guest_email: alex@example.com
              guest_phone: '+15145551234'
              start_datetime: '2026-05-10T14:00:00.000Z'
              end_datetime: '2026-05-10T15:00:00.000Z'
              timezone: America/Montreal
              notes: Premier rendez-vous découverte
      responses:
        '201':
          description: Rendez-vous créé.
          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: null
                  project_id: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
                  guest_name: Alex Tremblay
                  guest_email: alex@example.com
                  guest_phone: '+15145551234'
                  start_datetime: '2026-05-10T14:00:00.000Z'
                  end_datetime: '2026-05-10T15:00:00.000Z'
                  timezone: America/Montreal
                  status: confirmed
                  source: api
                  notes: Premier rendez-vous découverte
                  meeting_link: null
                  video_provider: null
                  cancelled_at: null
                  cancel_reason: null
                  assigned_to_id: null
                  zoom_join_url: 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'
        '404':
          $ref: '#/components/responses/ResourceNotFound'
        '415':
          $ref: '#/components/responses/InvalidContentType'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/bookings/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceIdParam'
    delete:
      tags:
        - Bookings
      operationId: cancelBooking
      summary: Annuler un rendez-vous (soft cancel)
      description: |
        **Soft cancel** — le rendez-vous n'est pas supprimé physiquement :
        son `status` passe à `cancelled` et `cancelled_at` est horodaté.
        La réponse `200` retourne ces 3 champs (et non `204 No Content`).

        Un second `DELETE` sur un rendez-vous déjà annulé retourne
        `409 already_cancelled` — l'opération n'est pas idempotente côté
        statut HTTP.

        Pour modifier un rendez-vous existant (changement d'horaire, de
        notes), il faut l'annuler et en créer un nouveau — il n'y a pas
        de méthode `PATCH` sur cet endpoint.
      security:
        - ApiKeyAuth:
            - bookings:write
      responses:
        '200':
          description: Rendez-vous annulé. Retourne le statut et l'horodatage de l'annulation.
          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-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'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/quotes:
    get:
      tags:
        - Quotes
      operationId: listQuotes
      summary: Lister les devis
      description: |
        Retourne la liste paginée des devis du client (cursor v2). Tri stable
        `created_at DESC, id DESC`.

        Les devis archivés (`archived_at IS NOT NULL`) sont exclus par
        défaut — l'API publique ne les expose pas. Pour récupérer un devis
        archivé, utiliser le dashboard.

        ### Filtres

        - `?status` — filtre exact (`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: Filtre exact sur le cycle de vie du devis.
      responses:
        '200':
          description: Liste paginée des devis.
          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: Créer un devis
      description: |
        Crée un devis vide (`status="draft"`, `blocks=[]`). Pour ajouter
        des lignes, calculer les montants, ou envoyer le devis au prospect,
        utiliser le dashboard ou l'endpoint `POST /v1/quotes/{id}/send`.

        Le `quote_number` doit être unique par client (contrainte DB) — un
        doublon retourne `500 internal_error` (pas encore mappé en
        `409 duplicate_resource`).
      security:
        - ApiKeyAuth:
            - quotes:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuoteCreate'
            example:
              quote_number: DEV-2026-0042
              prospect_name: Alex Tremblay
              prospect_email: alex@example.com
              prospect_phone: '+15145551234'
              prospect_company: Acme Inc.
              valid_until: '2026-06-30'
              payment_terms: Net 30
      responses:
        '201':
          description: Devis créé.
          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'
        '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: Mettre à jour un devis
      description: |
        Met à jour les champs fournis du devis (champs absents conservés).
        Au moins un champ doit être fourni sinon `400 no_fields`.

        Les montants (`subtotal`, `tps_amount`, `tvq_amount`, `total`) ne
        sont pas éditables via PATCH — calculés serveur-side à partir des
        blocks (gérés via le dashboard).

        Modifier `status` directement contourne le flow standard
        `POST /v1/quotes/{id}/send` (qui horodate `sent_at` automatiquement).
        À utiliser avec discernement (ex passer un draft à `cancelled`).

        Les devis archivés (`archived_at IS NOT NULL`) ne sont pas
        modifiables — retourne `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: Devis mis à jour.
          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'
        '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: Marquer un devis comme envoyé
      description: |
        Met à jour `status="sent"` et horodate `sent_at`. **N'envoie pas
        d'email réellement** — la délivrance email passe par le dashboard
        Capturia (Resend + templates). Cette limitation est documentée
        dans la réponse via un champ top-level `warning`.

        ### Forme de réponse non-standard

        La réponse retourne `{data, warning}` au lieu de `{data}` seul —
        le champ `warning` top-level n'est pas dans la convention v1
        standard. Documenter dans le code intégrateur que ce champ peut
        être présent et indique une limitation fonctionnelle, pas une
        erreur.

        ### États terminaux

        - `409 already_signed` si le devis est déjà signé (statut terminal).
        - `409 quote_cancelled` si le devis a été annulé (non renvoyable).

        Aucun body n'est requis — l'opération est purement transitionnelle.
      security:
        - ApiKeyAuth:
            - quotes:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: Body optionnel — aucun champ utilisé aujourd'hui (réservé pour usage futur).
      responses:
        '200':
          description: |
            Devis marqué comme envoyé. Réponse non-standard avec un champ
            `warning` top-level signalant que l'API ne déclenche pas
            l'envoi email réel.
          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'
                warning: Status updated to sent. Email delivery via API is not yet supported — use the dashboard to send quotes with email notifications.
        '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/tags:
    get:
      tags:
        - Tags
      operationId: listTags
      summary: Lister les tags
      description: |
        Retourne la liste paginée des tags du client (cursor v2). Tri stable
        `name ASC, id ASC` — l'ordre alphabétique facilite la construction
        d'une UI de sélection. Le tiebreaker `id ASC` évite qu'un doublon de
        nom (autorisé en base) ne provoque skip ou duplication entre pages.

        ### Sanitization du curseur

        Le `name` étant user-controlled, le curseur encode les valeurs avec
        échappement PostgREST côté serveur — pas d'action requise côté client.
      security:
        - ApiKeyAuth:
            - tags:read
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Liste paginée des 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
                    created_at: '2026-05-04T10:00:00.000Z'
                  - id: 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
                    name: Restauration
                    color: '#3b82f6'
                    description: null
                    category: industry
                    created_at: '2026-05-04T09:30:00.000Z'
                pagination:
                  next_cursor: null
                  has_more: false
                  limit: 20
                  cursor: null
                  total_count: 2
        '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: Créer un tag
      description: |
        Crée un nouveau tag dans la bibliothèque du client. Seul `name` est
        obligatoire — `color` reçoit la valeur par défaut `#6b7280` (gris
        neutre) si non fournie. Pour attacher le tag à un contact existant,
        utiliser `POST /v1/contacts/{id}/tags` avec l'identifiant retourné
        par cet endpoint.

        Aucune contrainte d'unicité sur `name` — deux tags peuvent partager
        le même nom (différenciés par leur `id` et `color`).
      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 créé.
          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
                  created_at: '2026-05-04T10:00:00.000Z'
        '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/automations:
    get:
      tags:
        - Automations
      operationId: listAutomations
      summary: Lister les automations
      description: |
        Retourne la liste paginée des automations actives du client (cursor v2).
        Tri stable `created_at DESC, id DESC`. Les automations archivées
        (`archived_at IS NOT NULL`) sont exclues — l'API publique ne les
        expose pas.

        ### Filtres

        - `?status=active` — uniquement les automations `is_active=true`.
        - `?status=inactive` — uniquement les automations `is_active=false`.

        ### Lecture seule

        L'API publique expose seulement la lecture (liste + historique) et le
        déclenchement manuel. La création/édition/archivage d'une automation
        passe par le dashboard — aucun endpoint POST/PATCH/DELETE
        `/v1/automations` n'existe.
      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: Filtre `is_active=true` (active) ou `is_active=false` (inactive).
      responses:
        '200':
          description: Liste paginée des 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: Déclencher manuellement une automation
      description: |
        Crée une exécution `status="pending"` avec `trigger_type="manual_api"`,
        réclamable par le moteur d'automations (asynchrone, pas de garantie de
        complétion synchrone). Pour suivre la progression de l'exécution,
        interroger `GET /v1/automations/{id}/history`.

        ### Préconditions

        - L'automation doit appartenir au client appelant et ne pas être
          archivée — sinon `404 not_found`.
        - L'automation doit avoir `is_active=true` — sinon
          `409 automation_inactive`.
        - L'automation doit être publiée — sinon `409 automation_not_published`.
        - L'automation publiée doit posséder un noeud déclencheur — sinon
          `409 no_trigger`.
        - Le `contact_id` doit appartenir au client appelant — sinon
          `404 not_found`.

        ### Gardes d'enrôlement

        Le déclenchement manuel passe par les mêmes gardes que les déclencheurs
        automatiques :

        - Contact déjà dans un parcours actif de cette automation — sinon
          `409 already_enrolled` (activer la multi-opportunité dans les réglages
          d'enrôlement pour autoriser des parcours concurrents).
        - Doublon dans les 5 dernières minutes, plafond de 5 enrôlements par
          contact par 24 h sur cette automation, ou plafond global de 200
          exécutions par heure pour le compte — sinon `429 trigger_throttled`.

        Le body accepte un `event_data` optionnel (objet JSON libre) injecté dans
        l'exécution et exploitable par les variables de template du graphe.
      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: Exécution `pending` créée — réclamable par le moteur de manière asynchrone.
          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: Lister l'historique d'exécutions d'une automation
      description: |
        Retourne la liste paginée des exécutions d'une automation (cursor v2).
        Tri stable `created_at DESC, id DESC` — les exécutions récentes en
        premier. Une exécution représente un passage de l'automation pour un
        contact donné ; le moteur peut la redémarrer après échec
        (`retry_count > 0`).

        L'automation doit appartenir au client appelant et ne pas être
        archivée — sinon `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: Liste paginée des exécutions de l'automation.
          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: Lister les opportunités du pipeline
      description: |
        Retourne la liste paginée des deals (opportunités) du client (cursor v2).
        Tri stable `created_at DESC, id DESC`.

        Un deal n'est PAS une entité distincte en base : c'est un
        `client_contacts` ayant `pipeline_stage_id IS NOT NULL`. Le sous-ensemble
        de champs exposé est volontairement réduit aux infos pertinentes pour
        la vue pipeline (coordonnées + stage + valeur + score). Pour le contact
        complet (custom_fields, tags, notes), utiliser `GET /v1/contacts/{id}`.

        ### Filtres

        - `?stage_id` (uuid) — filtre exact sur la stage du pipeline.

        ### Pas de GET single

        Aucun endpoint `GET /v1/pipeline/deals/{id}` n'est exposé — pour
        récupérer un deal individuel, utiliser `GET /v1/contacts/{id}` ou
        filtrer cette liste par `?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: Filtre exact sur la stage du pipeline.
      responses:
        '200':
          description: Liste paginée des 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: Placer un contact dans le pipeline
      description: |
        Place un contact existant dans une stage du pipeline (set
        `pipeline_stage_id`). **Ne crée pas de contact** — `contact_id` doit
        déjà exister et appartenir au client appelant (sinon `404 not_found`).
        De même pour `stage_id` (`404 not_found` si la stage n'appartient pas
        au client).

        Si le contact est déjà dans une stage, son `pipeline_stage_id` est
        écrasé par la nouvelle valeur — pas d'historique de mouvement
        enregistré (la table `pipeline_history` référence l'autre table
        interne `contacts`, pas `client_contacts`).
      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 placé dans le 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'
        '415':
          $ref: '#/components/responses/InvalidContentType'
        '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: Déplacer un deal vers une autre stage
      description: |
        Déplace un deal (`pipeline_stage_id`) vers une nouvelle stage. Seul le
        déplacement de stage est exposé via cet endpoint — pour modifier les
        autres champs du deal (email, deal_value, etc.) utiliser
        `PATCH /v1/contacts/{id}`.

        ### Préconditions

        - Le deal cible doit exister, appartenir au client appelant, et avoir
          `pipeline_stage_id IS NOT NULL` — sinon `404 not_found`.
        - La stage cible doit appartenir au client appelant — sinon
          `404 not_found`.
      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 déplacé vers la nouvelle 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'
        '415':
          $ref: '#/components/responses/InvalidContentType'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      tags:
        - Pipeline
      operationId: removeDeal
      summary: Retirer un deal du pipeline
      description: |
        Retire le deal du pipeline en mettant `pipeline_stage_id = NULL`. Le
        contact n'est PAS supprimé — il reste accessible via
        `GET /v1/contacts/{id}`. Pour supprimer définitivement le contact,
        utiliser `DELETE /v1/contacts/{id}`.

        Le deal cible doit exister, appartenir au client appelant, et avoir
        `pipeline_stage_id IS NOT NULL` — sinon `404 not_found`.
      security:
        - ApiKeyAuth:
            - pipeline:write
      responses:
        '200':
          description: Deal retiré du pipeline (le contact n'est pas supprimé).
          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
        '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/stages:
    get:
      tags:
        - Pipeline
      operationId: listStages
      summary: Lister les stages du pipeline
      description: |
        Retourne la liste complète des stages du pipeline du client, triées par
        `stage_order ASC`. Chaque stage est enrichie d'un compteur
        `contact_count` calculé en temps réel (nombre de contacts du client
        actuellement positionnés sur cette stage — utile pour les KPI de
        pipeline).

        ### Réponse non-paginée

        L'endpoint retourne **toutes les stages du client en un seul appel**
        (généralement < 20 par client, max 50). Le format de réponse reste
        compatible `list V2` pour la cohérence des intégrations
        (`pagination.next_cursor=null`, `pagination.has_more=false`,
        `pagination.limit` et `pagination.total_count` égalent le nombre de
        stages retournées).

        ### Lecture seule

        Création / édition / réordonnancement des stages passe par le
        dashboard — aucun endpoint POST/PATCH/DELETE `/v1/pipeline/stages`
        n'est exposé.
      security:
        - ApiKeyAuth:
            - pipeline:read
      responses:
        '200':
          description: Liste complète des stages du pipeline (non-paginée).
          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
                  - 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
                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: Lister les conversations email
      description: |
        Retourne la liste paginée des threads email du client (cursor v2). Tri
        stable `last_message_at DESC, id DESC` — les conversations avec un
        échange récent en premier. `last_message_at` est `NOT NULL` côté table.

        ### Lecture seule

        L'API publique expose seulement la liste des threads. Le détail des
        messages individuels d'une conversation n'est pas encore exposé via
        l'API publique — le payload se limite aux métadonnées du thread.
        Pour envoyer un email, utiliser `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: Liste paginée des conversations email.
          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: Envoyer un email transactionnel
      description: |
        Met en queue un email transactionnel pour livraison via Resend.
        L'email est créé avec `status="queued"`, `priority=1`,
        `type="transactional"` et délivré de manière asynchrone par le worker
        email — la réponse 201 confirme la mise en queue, pas la délivrance
        finale.

        ### Expéditeur résolu serveur-side

        L'expéditeur (`from`) est dérivé du sender par défaut du client
        (`client_email_senders.is_default = true`). Si aucun sender par
        défaut n'est configuré, la requête retourne
        `400 no_sender_configured`.

        ### Idempotency

        Le header `Idempotency-Key` n'est PAS supporté sur cette route — un
        retry naïf créera un second envoi. Une clé d'idempotence interne est
        générée serveur-side (`api-transactional/{clientId}/{ts}`) pour
        protéger uniquement contre les doubles inserts en base.

        ### Mode test

        `"test": true` dans le body permet de valider une intégration sans
        viser un vrai contact : les variables de personnalisation sont
        interpolées avec des valeurs d'exemple, le sujet est préfixé
        `[TEST]`, et les vérifications de conformité destinataire sont
        contournées. En contrepartie, le destinataire doit être un membre
        actif de l'équipe du client (sinon 400 `recipient_not_allowed`) et
        un quota de 50 tests / 24 h glissantes s'applique, partagé avec les
        boutons de test de la plateforme (sinon 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 mis en queue.
          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: Lister les souscriptions webhooks
      description: |
        Retourne la liste des souscriptions webhooks créées par la clé API
        appelante. Les souscriptions créées par une autre clé du même client
        ne sont PAS visibles ici (filtre `client_id AND api_key_id`).

        ### Pas de pagination cursor

        Le maximum de 10 souscriptions par client tient toujours dans une
        seule page. La réponse retourne `pagination.next_cursor=null` et
        `has_more=false`.

        ### Secret jamais retourné

        Le secret HMAC utilisé pour signer les payloads sortants n'est
        retourné qu'à la création (`POST /v1/webhooks`). Il n'est pas
        relu ici — pour le faire tourner, supprimer la souscription et
        en créer une nouvelle.
      security:
        - ApiKeyAuth:
            - webhooks:manage
      responses:
        '200':
          description: Liste des souscriptions webhooks de cette clé API.
          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: Créer une souscription webhook
      description: |
        Enregistre une nouvelle souscription. Capturia émettra un `POST` JSON
        signé HMAC à `url` chaque fois qu'un événement souscrit survient.

        ### URL HTTPS obligatoire

        L'URL doit utiliser le schéma `https://` — `http://` est rejeté pour
        éviter la fuite des payloads en clair.

        ### Limite de 10 souscriptions par client

        Au-delà de 10 souscriptions actives sur un même client (toutes clés
        confondues), retourne `429 subscription_limit_reached`. Supprimer
        une souscription existante avant d'en créer une nouvelle.

        ### Secret HMAC retourné UNE seule fois

        La réponse 201 inclut `secret` en clair (64 caractères hex, 256 bits
        d'entropie). Le client DOIT le stocker immédiatement — il sert à
        vérifier la signature des payloads sortants
        (`X-Capturia-Signature`) et n'est plus jamais retourné par l'API.
        Le secret est chiffré en base après cette réponse.

        ### Scope de la clé créatrice

        La souscription appartient à la fois au client ET à la clé API qui
        l'a créée. Les endpoints `GET /v1/webhooks` et
        `DELETE /v1/webhooks/{id}` ne voient que les souscriptions de la
        clé courante — une souscription créée par une autre clé du même
        client n'est ni listée ni supprimable via l'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: |
            Souscription créée. Le `secret` HMAC est inclus dans la réponse
            en clair UNE seule fois — stocker immédiatement.
          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: Supprimer une souscription webhook
      description: |
        Supprime définitivement une souscription webhook. La souscription
        doit appartenir à la fois au client appelant ET à la clé API
        appelante (filtre `client_id AND api_key_id`) — sinon
        `404 not_found`.

        ### Pas de soft delete

        La suppression est immédiate et irréversible — pour réactiver le
        flux d'événements, créer une nouvelle souscription via
        `POST /v1/webhooks` (un nouveau secret sera émis).
      security:
        - ApiKeyAuth:
            - webhooks:manage
      responses:
        '204':
          description: Souscription supprimée. Pas de 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: Lister les derniers événements métier livrables (échantillons)
      description: |
        Retourne les derniers événements métier du compte, dans **exactement
        la forme du payload livré par une souscription webhook** — le même
        constructeur de payload sert la livraison réelle et cette liste.

        ### À quoi ça sert

        - **`performList` des apps Zapier/Make** : quand un utilisateur teste
          un trigger, l'app affiche de vrais échantillons récents, garantis
          identiques à ce qu'une livraison réelle enverra.
        - **Debug d'intégration** : inspecter ce qui serait livré sans
          attendre un nouvel événement.

        La liste fonctionne même si aucune souscription webhook n'existe :
        elle est bâtie depuis le journal d'activité du compte, filtré au
        catalogue des événements livrables. Les types d'événements internes
        (audit) n'apparaissent jamais, et les clés sensibles du champ
        `changes` sont expurgées comme à la livraison.

        ### Différence avec une livraison réelle

        Le champ `id` de chaque item est l'identifiant de l'événement dans
        le journal d'activité — lors d'une livraison réelle, `id` est
        l'identifiant unique de la livraison (`X-Capturia-Delivery`). La
        forme et tous les autres champs sont identiques.

        ### Pas de pagination cursor

        C'est un flux d'échantillons récents, plafonné à 25 items
        (`limit`, défaut 10). La réponse retourne
        `pagination.next_cursor=null` et `has_more=false`.
      security:
        - ApiKeyAuth:
            - webhooks:manage
      parameters:
        - name: event
          in: query
          required: false
          description: |
            Restreint aux événements de ce type (une valeur du catalogue,
            ex. `lead_created`, `booking_created`, `automation_completed`).
            Une valeur hors catalogue répond `400 validation_error`
            (`reason: unknown_event_type`).
          schema:
            type: string
            example: lead_created
        - name: limit
          in: query
          required: false
          description: Nombre maximum d'items (entier de 1 à 25, défaut 10). Une valeur non entière ou hors bornes répond `400 validation_error`.
          schema:
            type: integer
            minimum: 1
            maximum: 25
            default: 10
      responses:
        '200':
          description: Derniers événements livrables, du plus récent au plus ancien.
          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: Identifiant de l'événement dans le journal d'activité.
                        event:
                          type: string
                          description: Type d'événement (valeur du catalogue).
                        category:
                          type: string
                          description: Catégorie de l'événement (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
                                  enum:
                                    - client_user
                                    - team_member
                                id:
                                  type:
                                    - string
                                    - 'null'
                            changes:
                              type:
                                - object
                                - 'null'
                              description: Diff des champs modifiés, clés sensibles expurgées.
                            description:
                              type:
                                - string
                                - 'null'
                            metadata:
                              type: object
                  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: contact
                      resource_id: b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e
                      resource_name: Jean Bouchard
                      actor:
                        name: Formulaire web
                        type: client_user
                        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
                      changes: null
                      description: Nouveau lead
                      metadata:
                        source: funnel
                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: Soumettre un formulaire d'outil pré-suasion
      description: |
        **Endpoint public — convention legacy pré-CAP-440.** Cet endpoint
        s'écarte des conventions standard de l'API v1 sur plusieurs points
        documentés ci-dessous. Il est appelé par la gateway pré-suasion
        (`demo.capturia.io`) quand un visiteur complète un formulaire
        d'outil pré-suasion publié sur le custom domain d'un client
        Capturia.

        ### Authentification : défense en profondeur

        Pas de clé API Bearer (l'endpoint est appelé par une gateway publique,
        pas par un client tiers). Trois couches superposées protègent
        l'endpoint :

        1. **Rate-limit IP** (`RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT` req par
           `LEAD_WINDOW_SECONDS`) — coupe le bruit haute fréquence.
        2. **Signature HMAC** `X-Capturia-Signature: t=<unix>,v1=<hex>` —
           pattern Stripe webhook, SHA-256 sur `${timestamp}.${rawBody}`,
           fenêtre replay 5 min, rotation 2-clés (`PRESUASION_HMAC_SECRET` +
           `PRESUASION_HMAC_SECRET_PREVIOUS`). Mode log-only par défaut, flip
           fail-closed via `PRESUASION_HMAC_REQUIRED=true`.
        3. **Token Cloudflare Turnstile** (champ `turnstile_token` du body)
           vérifié siteverify avec `idempotency_key` = `submission_id` du
           body (stable entre les retries d'une même soumission — un token
           est à usage unique, Cloudflare rejoue le verdict original sur la
           même clé), avec repli sur le `request_id` de la requête quand la
           soumission ne porte pas de `submission_id`. Mode log-only par
           défaut, flip fail-closed via
           `PRESUASION_TURNSTILE_REQUIRED=true`.

        En complément, un **quota par `proposition_slug`** (100 submissions
        par jour UTC, Upstash KV) borne le blast radius d'une attaque ciblée
        même si les autres couches sont contournées.

        Le client cible est résolu serveur-side via `proposition_slug` →
        `presuasion_instances.client_id` — le `client_id` n'est jamais
        accepté depuis le body.

        ### Body et réponse au format custom

        Le body suit un schéma propre à cet endpoint
        (`PresuasionSubmissionPayload`). La réponse 200 retourne un objet
        plat `{success, contact_id, contact_created, activity_id}` au lieu
        de l'enveloppe v1 standard `{data: ...}`. Les erreurs retournent
        `{error: "string", issues?: [...]}` au lieu d'`ErrorEnvelope`.

        ### Comportement

        - Resolve le client cible via `proposition_slug` actif.
        - Upsert le contact par `(lower(email), client_id)` — enrichit les
          champs absents (`first_name`, `phone`) sans jamais écraser les
          valeurs existantes non-null.
        - Insère une activité `presuasion_submission` dans la timeline du
          contact avec un bloc humain `Q/R` formaté + `metadata` brute
          (responses, result_data, tool_id/name, proposition).

        ### Rate limit

        Émet les headers legacy `X-RateLimit-*` (compteur IP) — pas les
        headers `RateLimit-*` RFC 9331 du reste de l'API v1.
      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: |
            Signature HMAC SHA-256 calculée par le gateway sur
            `${timestamp}.${rawBody}` avec `PRESUASION_HMAC_SECRET`. Fenêtre
            de replay 5 minutes. Optionnel en mode log-only ; obligatoire
            quand `PRESUASION_HMAC_REQUIRED=true` (réponse 401 sinon).
      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: |
            Soumission enregistrée. Contact upserté + activité
            `presuasion_submission` créée dans la 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: |
            Body JSON invalide ou payload Zod invalide. **Convention
            legacy** — body custom `{error, issues?}` non aligné avec
            `ErrorEnvelope` du catalog v1.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              examples:
                invalidJson:
                  summary: JSON malformé
                  value:
                    error: Invalid JSON body
                invalidPayload:
                  summary: Validation Zod échouée
                  value:
                    error: Invalid payload
                    issues:
                      - path: formatted_answers.0.question
                        message: String must contain at least 1 character(s)
        '401':
          description: |
            Signature HMAC `X-Capturia-Signature` manquante, mal formée,
            expirée (fenêtre 5 min) ou invalide. Émis uniquement quand
            `PRESUASION_HMAC_REQUIRED=true` (mode fail-closed). En mode
            log-only, les requêtes non signées passent et un warning est
            émis pour observabilité.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Invalid signature
        '403':
          description: |
            Token Cloudflare Turnstile manquant, expiré ou rejeté par
            siteverify. Émis uniquement quand
            `PRESUASION_TURNSTILE_REQUIRED=true` (mode fail-closed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Captcha verification failed
        '404':
          description: |
            `proposition_slug` inconnu ou désactivé
            (`presuasion_instances.is_active = false`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Proposition not found or inactive
        '409':
          description: |
            Le `proposition_slug` existe mais n'est pas relié à un client
            Capturia (`client_id IS NULL`). Erreur de configuration côté
            admin — la soumission est rejetée plutôt que silencieusement
            droppée.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              example:
                error: Proposition not linked to a Capturia client
        '429':
          description: |
            Deux variantes possibles :

            * **Rate limit IP dépassé** — body `{error: "Too many requests"}`,
              headers `X-RateLimit-*` (convention legacy).
            * **Quota par `proposition_slug` dépassé** (100/jour UTC) — body
              `{error: "Slug quota exceeded"}` accompagné du header standard
              `Retry-After` (secondes jusqu'à minuit UTC) en plus des
              `X-RateLimit-*`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              examples:
                ipRateLimit:
                  summary: Rate limit IP
                  value:
                    error: Too many requests
                slugQuota:
                  summary: Quota slug journalier dépassé
                  value:
                    error: Slug quota exceeded
        '500':
          description: |
            Erreur serveur (lookup proposition, upsert contact ou insert
            activité). **Convention legacy** — body custom
            `{error: "string"}` au lieu d'`ErrorEnvelope`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresuasionSubmissionError'
              examples:
                serverError:
                  summary: Erreur générique (lookup proposition)
                  value:
                    error: Server error
                upsertFailed:
                  summary: Upsert contact échoué
                  value:
                    error: Failed to upsert contact
                activityFailed:
                  summary: Insert activité échoué
                  value:
                    error: Failed to record activity
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: cap_live_<hex64>
      description: |
        Authentification par clé API au format `cap_live_<hex64>` passée en header
        `Authorization: Bearer cap_live_...`. Les clés legacy au format `cap_<hex64>`
        sont également acceptées jusqu'au 2027-05-03.

        ## Scopes disponibles

        Chaque clé porte une liste de scopes qui déterminent quelles opérations elle
        peut effectuer. Les scopes sont vérifiés à chaque requête côté serveur.
        19 scopes au total : 8 scopes de lecture, 9 scopes d'écriture/déclenchement,
        et 2 scopes d'ingestion.

        ### Scopes de lecture

        - `contacts:read` — Lire les contacts
        - `pipeline:read` — Lire les deals et stages du pipeline
        - `conversations:read` — Lire les threads de conversation (chat, SMS, appel, email)
        - `bookings:read` — Lire les rendez-vous
        - `quotes:read` — Lire les devis
        - `tags:read` — Lire les tags
        - `automations:read` — Lire les automations
        - `email:read` — Lire les threads email

        ### Scopes d'écriture

        - `contacts:write` — Créer/modifier/supprimer des contacts
        - `pipeline:write` — Créer/modifier des deals
        - `bookings:write` — Créer/modifier des rendez-vous
        - `quotes:write` — Créer/modifier des devis
        - `tags:write` — Créer/modifier des tags
        - `automations:trigger` — Déclencher manuellement une automation
        - `email:send` — Envoyer un email transactionnel
        - `webhooks:manage` — Créer/modifier des webhooks sortants
        - `campaigns:launch` — Prévisualiser puis lancer une campagne de relance SMS (connecteur MCP, confirmation en 2 temps obligatoire)

        ### Scopes d'ingestion

        - `leads:capture` — Pousser des leads depuis un funnel externe (le plus utilisé pour intégrations partenaires)
        - `events:ingest` — Pousser des événements personnalisés qui déclenchent les automatisations (`POST /v1/events`)

        ## Presets

        Pour simplifier la création, 5 presets de scopes sont proposés à la création :
        `Capture de leads` (`leads:capture` seul), `Lecture seule` (tous les `:read`),
        `Lecture + écriture` (tous `:read` + `:write` + `leads:capture` + `events:ingest`),
        `Intégration complète` (tous les scopes), `Personnalisé` (sélection libre).

        Documentation complète : https://capturia.io/fr/developers/api/v1/authentication
  headers:
    XRequestID:
      description: ULID de la requête, utilisable pour debug avec le support.
      schema:
        type: string
        pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
    CapturiaRequestID:
      description: Alias de X-Request-ID (compat avec clients qui filtrent les headers `X-*` legacy).
      schema:
        type: string
        pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
    CapturiaVersion:
      description: Version de l'API qui a servi la response (toujours `v1` aujourd'hui).
      schema:
        type: string
        enum:
          - v1
    RateLimitLimit:
      description: Limite de requêtes par fenêtre (RFC 9331).
      schema:
        type: integer
    RateLimitRemaining:
      description: Requêtes restantes dans la fenêtre en cours (RFC 9331).
      schema:
        type: integer
    RateLimitReset:
      description: Secondes avant reset de la fenêtre (RFC 9331, delta-seconds).
      schema:
        type: integer
    RetryAfter:
      description: Secondes à attendre avant de retry (RFC 7231). Émis sur 429 et 503.
      schema:
        type: integer
    RateLimitPolicy:
      description: Politique de rate limit en format structuré (RFC 9331), ex `100;w=3600`.
      schema:
        type: string
        example: 100;w=3600
    XRateLimitLimit:
      description: Limite de requêtes (legacy, sunset 2027-05-03).
      deprecated: true
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requêtes restantes (legacy, sunset 2027-05-03).
      deprecated: true
      schema:
        type: integer
    XRateLimitReset:
      description: Timestamp ISO du reset (legacy, sunset 2027-05-03).
      deprecated: true
      schema:
        type: string
        format: date-time
  schemas:
    ApiScope:
      type: string
      description: |
        Scope d'autorisation porté par une clé API. Les scopes contrôlent quelles
        routes sont accessibles : `:read` pour lecture, `:write` pour mutation,
        et quelques scopes spécifiques (`leads:capture`, `events:ingest`,
        `automations:trigger`, `email:send`, `webhooks:manage`,
        `campaigns:launch`). La liste effective d'une clé est figée à sa
        création — pour changer les scopes, créer une nouvelle clé.
      enum:
        - contacts:read
        - contacts:write
        - 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 inféré depuis le set de scopes effectifs de la clé (pas stocké en
        DB — recalculé au runtime).

        - `lead_capture` — scopes = `[leads:capture]` exactement
        - `read_only` — tous les scopes `:read`
        - `read_write` — tous les `:read` + tous les `:write` + `leads:capture` + `events:ingest`
        - `full_integration` — tous les scopes disponibles
        - `custom` — toute combinaison qui ne matche aucun preset connu
      enum:
        - lead_capture
        - read_only
        - read_write
        - full_integration
        - custom
      example: lead_capture
    Timestamp:
      type: string
      format: date-time
      description: Timestamp ISO 8601 UTC.
      example: '2026-05-04T12:34:56.789Z'
    ApiKeyMe:
      type: object
      description: |
        Vue détaillée de la clé API authentifiée, retournée par `GET /v1/me`.
        Utilisée comme outil de smoke test — un dev qui intègre Capturia confirme
        rapidement que sa clé fonctionne et lit les infos qu'elle expose
        (client cible, scopes, preset, expiration, rate limit applicable).

        Le secret de la clé n'est jamais retourné — seul `key_prefix` (8-12
        premiers caractères type `cap_live_…`) sert d'identifiant lisible côté
        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: Identifiant unique interne de la clé.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        key_name:
          type: string
          description: Nom lisible donné à la clé lors de sa création (libellé dashboard).
          example: Make.com production
        key_prefix:
          type: string
          description: |
            Préfixe non-secret de la clé (ex `cap_live_a1b2c3`) — affiché côté
            dashboard pour identifier visuellement la clé sans exposer le secret.
          example: cap_live_a1b2c3
        key_format_version:
          type: integer
          description: |
            Version du format de la clé (1, 2, …). Permet de gérer les rotations
            de format sans casser les clés existantes. Les nouvelles clés
            utilisent toujours la dernière version.
          example: 2
        client_id:
          type: string
          format: uuid
          description: Client Capturia auquel la clé donne accès (isolation tenant).
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        client_name:
          type:
            - string
            - 'null'
          description: |
            Nom lisible du client cible. `null` si le client n'a pas de nom
            renseigné (cas rare, comptes système).
          example: Acme Inc.
        scopes:
          type: array
          description: |
            Scopes effectifs portés par la clé (filtrés contre la liste des
            scopes valides — d'éventuels scopes inconnus en DB sont écartés).
          items:
            $ref: '#/components/schemas/ApiScope'
          example:
            - leads:capture
        preset:
          $ref: '#/components/schemas/ApiKeyPreset'
        lifecycle_state:
          type: string
          description: |
            État de cycle de vie de la clé. Valeurs typiques : `active`,
            `paused`, `revoked`. Une clé `paused` ou `revoked` ne passe pas
            l'authentification — si vous voyez `active` ici, c'est que la clé
            fonctionne.
          example: active
        created_at:
          $ref: '#/components/schemas/Timestamp'
        last_used_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            Horodatage du dernier appel API authentifié avec cette clé. `null`
            si jamais utilisée.
          example: '2026-05-04T12:34:56.789Z'
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            Horodatage d'expiration prévu. `null` si la clé n'expire pas
            automatiquement. Une clé expirée renvoie 401 `expired_api_key`.
          example: '2027-05-04T00:00:00.000Z'
        rate_limits:
          type: object
          description: Rate limit par-clé applicable à toutes les routes v1.
          required:
            - per_hour
          properties:
            per_hour:
              type: integer
              minimum: 0
              description: Nombre maximum de requêtes par heure glissante pour cette clé — même fenêtre que le header RateLimit-Policy (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: Catégorie machine-readable de l'erreur.
        code:
          type: string
          description: Identifiant stable de l'erreur (cf doc_url pour la liste complète).
          example: insufficient_scope
        message:
          type: string
          description: Message human-readable localisé selon Accept-Language (fr ou en).
        hint:
          type: string
          description: Suggestion actionnable pour résoudre l'erreur (ex « Ajoute le scope X »).
        doc_url:
          type: string
          format: uri
          description: Lien vers la documentation de cette erreur spécifique.
          example: https://capturia.io/fr/developers/api/v1/authentication#scopes
        dashboard_url:
          type: string
          format: uri
          description: Lien dashboard actionnable (ex page d'édition des scopes de la clé qui a déclenché l'erreur).
        request_id:
          type: string
          pattern: ^req_[0-9A-HJKMNP-TV-Z]{26}$
          description: ULID de la requête. À fournir au support pour debug.
          example: req_01J9X8Z3F4K2M5N7P9Q1R3S5T7
        details:
          type: object
          description: Champs additionnels spécifiques au type d'erreur (validation_errors, retry_after_seconds, limit_type, etc.).
          additionalProperties: true
    UsageBucket:
      type: object
      description: |
        Agrégation d'usage sur une fenêtre temporelle (`api_usage_aggregations`).
        Une bucket représente une heure ou un jour selon la `granularity` demandée
        sur `GET /v1/usage`. Les latences sont calculées en millisecondes
        (percentiles serveur-side, `null` si aucune requête mesurée sur la fenêtre).
      required:
        - bucket_start
        - request_count
        - status_2xx
        - status_4xx
        - status_5xx
        - rate_limited_count
      properties:
        bucket_start:
          type: string
          format: date-time
          description: |
            Début de la fenêtre temporelle (timestamp ISO 8601 UTC). Pour
            granularité `hour` : aligné sur l'heure pleine. Pour `day` : aligné
            sur 00:00:00 UTC.
          example: '2026-05-04T12:00:00.000Z'
        request_count:
          type: integer
          minimum: 0
          description: Nombre total de requêtes authentifiées avec succès dans la fenêtre.
          example: 1234
        status_2xx:
          type: integer
          minimum: 0
          description: Nombre de réponses 2xx (succès).
          example: 1200
        status_4xx:
          type: integer
          minimum: 0
          description: |
            Nombre de réponses 4xx (erreur client : validation, auth, rate limit
            429 inclus, etc.).
          example: 30
        status_5xx:
          type: integer
          minimum: 0
          description: Nombre de réponses 5xx (erreur serveur).
          example: 4
        rate_limited_count:
          type: integer
          minimum: 0
          description: |
            Nombre de requêtes rejetées par rate limit (429). Sous-ensemble de
            `status_4xx` — utile pour dimensionner sa consommation.
          example: 5
        latency_p50_ms:
          type:
            - number
            - 'null'
          description: Latence médiane (p50) en millisecondes sur la fenêtre. `null` si non mesurable.
          example: 42.5
        latency_p95_ms:
          type:
            - number
            - 'null'
          description: Latence p95 en millisecondes.
          example: 180
        latency_p99_ms:
          type:
            - number
            - 'null'
          description: Latence p99 en millisecondes.
          example: 350
    UsageTotals:
      type: object
      description: |
        Totaux calculés application-side en sommant les `buckets` retournés
        (pas de latence agrégée — les percentiles ne sont pas additifs).
      required:
        - request_count
        - status_2xx
        - status_4xx
        - status_5xx
        - rate_limited_count
      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
    UsageResponse:
      type: object
      description: |
        Réponse de `GET /v1/usage`. Filtrée implicitement à la clé authentifiée
        (`api_key_id` = clé courante). La plage temporelle (`from` → `to`) doit
        être strictement positive et ne pas dépasser 90 jours
        (sinon 400 `invalid_request`).

        Les `buckets` sont triés par `bucket_start ASC`. Une fenêtre sans trafic
        est absente du tableau (les buckets ne sont pas zéro-paddées).
      required:
        - api_key_id
        - from
        - to
        - granularity
        - buckets
        - totals
      properties:
        api_key_id:
          type: string
          format: uuid
          description: Identifiant de la clé API mesurée (mirror de la clé authentifiée).
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        from:
          type: string
          format: date-time
          description: Borne inférieure de la plage demandée (ISO 8601 UTC, normalisée).
          example: '2026-05-01T00:00:00.000Z'
        to:
          type: string
          format: date-time
          description: Borne supérieure de la plage demandée (exclusive, ISO 8601 UTC, normalisée).
          example: '2026-05-04T00:00:00.000Z'
        granularity:
          type: string
          description: Granularité des buckets retournés.
          enum:
            - hour
            - day
          example: day
        buckets:
          type: array
          description: Buckets d'agrégation triés par `bucket_start ASC`. Vide si aucun trafic sur la plage.
          items:
            $ref: '#/components/schemas/UsageBucket'
        totals:
          $ref: '#/components/schemas/UsageTotals'
    LeadCaptureUtm:
      type: object
      description: |
        Paramètres UTM optionnels associés au lead. Sert à attribuer la source
        marketing dans le pipeline (`Source : Google Ads / Facebook campaign X`).
      additionalProperties: false
      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: |
        Réponse à une question de qualification posée en amont (formulaire
        funnel, quiz pré-suasion). Stockée pour enrichir le contexte du bot SMS
        et alimenter le scoring de lead.
      required:
        - question
        - answer
      additionalProperties: false
      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: |
        Routage optionnel du lead dans le pipeline de vente. Tous les champs sont
        optionnels — fournir au moins `pipeline_slug` pour cibler un pipeline.
        Si `stage_slug` est omis, le stage par défaut du pipeline est utilisé.
        `assigned_sales_rep_email` doit correspondre à un membre de l'équipe
        client (sinon `assignment_status` retourné = `email_not_found`).
      additionalProperties: false
      properties:
        pipeline_slug:
          type: string
          maxLength: 100
          description: Slug du pipeline cible (visible dans l'URL dashboard du pipeline).
          example: ventes-b2b
        stage_slug:
          type: string
          maxLength: 100
          description: |
            Slug du stage cible dans le pipeline. Si absent, le stage par défaut
            (`is_default=true`) du pipeline est utilisé.
          example: nouveau-lead
        deal_value:
          type: number
          minimum: 0
          maximum: 100000000
          description: Valeur estimée du deal en CAD (0 à 100 M).
          example: 2500
        expected_close_date:
          type: string
          pattern: ^\d{4}-\d{2}-\d{2}$
          description: |
            Date prévue de closing au format ISO `YYYY-MM-DD`. Format strict —
            un timestamp complet est rejeté.
          example: '2026-06-30'
        assigned_sales_rep_email:
          type: string
          format: email
          maxLength: 255
          description: |
            Email du vendeur à assigner. Doit correspondre à un membre actif du
            client appelant (`client_users.status = 'active'`) ayant déjà activé
            son compte (invitation acceptée). Sinon la capture continue sans
            assignation et `assignment_status = email_not_found` dans la réponse
            (pas une erreur).
          example: alex@acme.com
    LeadCaptureMetaAttribution:
      type: object
      description: |
        Attribution publicitaire Meta du visiteur, capturée à la soumission côté
        funnel et persistée (first-touch) sur le contact. Sert à attribuer
        l'événement Purchase server-side (Conversions API) au clic d'origine quand
        le deal passe « gagné ». Tous les champs sont optionnels — fournir ce qui
        est disponible améliore la qualité du matching Meta. Un lead qui revient ne
        réécrit pas son attribution d'origine (first-touch).

        Les identifiants `ad_id`/`adset_id`/`campaign_id` (et leurs libellés)
        rattachent en plus le lead à la publicité qui l'a amené — visible sur sa
        fiche et dans la performance par pub. Les connecteurs branchés sur Meta
        Lead Ads (Zapier, Make, n8n) disposent de ces champs dans le payload du
        lead.
      additionalProperties: false
      properties:
        fbc:
          type: string
          maxLength: 255
          description: Cookie de clic Facebook `_fbc` (transmis tel quel à Meta).
          example: fb.1.1700000000000.AbCdEf123
        fbp:
          type: string
          maxLength: 255
          description: Cookie navigateur Facebook `_fbp` (transmis tel quel à Meta).
          example: fb.1.1700000000000.987654321
        fbclid:
          type: string
          maxLength: 512
          description: Paramètre de clic Facebook `fbclid` (depuis l'URL d'atterrissage).
        event_source_url:
          type: string
          maxLength: 2048
          description: URL où la conversion a été initiée (page du funnel).
          example: https://immoacademie.com/offre
        client_ip_address:
          type: string
          maxLength: 45
          description: |
            Adresse IP du visiteur d'origine, transmise par le funnel (et non l'IP
            de l'appelant serveur). Acceptée pour la qualité du matching Meta.
        client_user_agent:
          type: string
          maxLength: 512
          description: User-Agent du navigateur du visiteur d'origine, transmis par le funnel.
        ad_id:
          type: string
          maxLength: 64
          description: Identifiant Meta de la publicité qui a généré le lead.
          example: '120210000000000001'
        adset_id:
          type: string
          maxLength: 64
          description: Identifiant Meta de l'ensemble de publicités.
        campaign_id:
          type: string
          maxLength: 64
          description: Identifiant Meta de la campagne.
          example: '120210000000000002'
        ad_name:
          type: string
          maxLength: 255
          description: Nom de la publicité chez Meta (libellé affiché sur la fiche contact).
          example: Pub verte — piscine creusée
        campaign_name:
          type: string
          maxLength: 255
          description: Nom de la campagne chez Meta.
          example: Usine à leads — octobre
        ads_consent:
          description: |
            Consentement publicitaire du lead (Loi 25). Omis = vous attestez que le
            consentement a été recueilli en amont (votre propre bandeau, ou un lead
            Meta Lead Ads) et les événements de conversion partent vers Meta.
            `false` = le lead a refusé le suivi publicitaire : aucun événement Meta
            n'est envoyé, aucun identifiant de suivi (`fbc`/`fbp`) n'est retenu, et
            le refus est persisté sur le contact — il bloque aussi ses événements
            futurs (vente gagnée, lead qualifié). Booléen, ou équivalent des
            connecteurs no-code : `1`/`0` numérique, ou chaîne
            (`"true"`/`"yes"`/`"oui"`/`"1"` et `"false"`/`"no"`/`"non"`/`"0"`,
            insensible à la casse). `null` vaut « champ omis ».
          oneOf:
            - type: boolean
            - type: string
            - type: number
            - type: 'null'
          example: false
    LeadCapturePayload:
      type: object
      description: |
        Body de capture d'un lead (`POST /v1/leads/capture`). `phone` et
        `sms_consent` sont obligatoires — Capturia est centré SMS et la
        captation suppose un opt-in explicite.

        `phone` est normalisé serveur-side avant validation : un numéro à 10
        chiffres sans indicatif est assumé NANP (`+1XXXXXXXXXX`), un numéro à
        11 chiffres commençant par `1` reçoit le `+`, et les formats avec
        parenthèses/tirets (`(514) 123-4567`, `1-514-123-4567`) sont acceptés.
        Pour un numéro international, fournir explicitement le `+` et
        l'indicatif pays (ex `+33612345678`). Un téléphone livré en nombre JSON
        (champ numérique d'un connecteur no-code) est accepté s'il est un entier
        positif — voir la propriété `phone`.

        `sms_consent` accepte le boolean `true` ou les chaînes truthy
        `"true" | "yes" | "oui" | "vrai" | "1" | "on" | "checked"`
        (insensible à la casse). Toute autre valeur est rejetée — Zapier, Make
        et la plupart des form-builders stringifient les booleans, alors
        accepter ces variantes courantes évite de perdre des consentements
        valides.

        Body cap : 1 MB. Au-delà : 400 `payload_too_large`.

        Tolérance no-code : les champs d'identité sont normalisés serveur-side
        (casse et accents ignorés, alias courants comme `firstName`, `Full Name`,
        `phone_number`, `Courriel` rapprochés des clés canoniques). Les clés non
        reconnues sont ignorées plutôt que rejetées, d'où `additionalProperties`.
        Un champ optionnel envoyé vide (`""` ou `null`) est traité comme absent
        plutôt que rejeté. `tags` accepte aussi une chaîne à virgules
        (`"chaud, webinaire"`), coercée en tableau.

        Webhooks GoHighLevel : l'action Webhook des workflows GHL enveloppe les
        paires « Custom Data » dans un objet imbriqué `customData`. Capturia
        déplie ce wrapper d'un niveau — chaque paire est traitée comme si elle
        était à la racine (mêmes alias, même validation), la racine gardant
        préséance en cas de doublon. Aucune configuration requise côté GHL.
      required:
        - phone
        - sms_consent
      additionalProperties: true
      properties:
        phone:
          description: |
            Téléphone (sera normalisé E.164). Format final attendu :
            `^\+[1-9]\d{10,14}$` (entre 11 et 15 chiffres après le `+`).
            Aussi accepté : un entier JSON positif dans la zone sûre JavaScript
            (au plus 9007199254740991 — tout numéro E.164 y tient), converti en
            chaîne avant normalisation ; connecteurs no-code dont le champ source
            est numérique. Préférer la chaîne — seule forme qui préserve un `+`
            international explicite. Tout autre nombre (négatif, décimal, zéro)
            est refusé en 422 `invalid_phone` avec le rappel des formats acceptés.
          oneOf:
            - type: string
            - type: integer
              minimum: 1
              maximum: 9007199254740991
          example: '+15145551234'
        sms_consent:
          description: |
            Consentement SMS explicite. Doit être `true` (boolean) ou une
            chaîne truthy whitelisted (voir description du schema). Les
            valeurs ambiguës (`false`, `"no"`, `0`, etc.) sont rejetées avec
            422 `missing_consent`. Accepté à la racine du body ou dans
            l'objet `customData` (webhooks GoHighLevel).
          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: |
            Libellé libre identifiant la source du lead (ex `funnel-fb-jan`,
            `landing-blueprint`). Affiché dans le pipeline et le journal
            d'activité du contact.
          example: funnel-fb-jan
        utm:
          $ref: '#/components/schemas/LeadCaptureUtm'
        notes:
          type: string
          maxLength: 2000
          description: Notes libres associées au lead (visible dans la fiche contact).
          example: Lead chaud — a déjà essayé un concurrent
        tags:
          type: array
          maxItems: 20
          description: |
            Tags à associer au contact créé. Si un tag n'existe pas pour ce
            client, il est créé à la volée.
          items:
            type: string
            maxLength: 50
          example:
            - lead-chaud
            - vu-webinaire
        qualification_answers:
          type: array
          maxItems: 20
          description: Liste de questions/réponses de qualification capturées en amont.
          items:
            $ref: '#/components/schemas/LeadCaptureQualificationAnswer'
        pipeline:
          $ref: '#/components/schemas/LeadCapturePipelineConfig'
        custom_fields:
          type: object
          maxProperties: 50
          description: |
            Champs personnalisés du contact, keyés par le NAME de la définition
            configurée côté client (ex `type_propriete`), pas par son UUID interne —
            l'integrator d'un site ne connaît pas les ids. La capture résout
            name → définition par client puis remplit la fiche contact et le kanban.
            Une clé sans définition correspondante est ignorée silencieusement (pas
            d'erreur). Pour un champ `select`, fournir l'option `value`
            (ex `maison_isolee`), pas le 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: Adresse postale du contact (texte libre, ex la propriété à vendre).
          example: 123 rue Principale, Montréal
        postal_code:
          type: string
          maxLength: 20
          description: Code postal du contact.
          example: H2X 1Y4
        address_place_id:
          type: string
          maxLength: 255
          description: |
            Identifiant de lieu (ex Google Place ID) si l'adresse provient d'un
            autocomplete. Stocké tel quel pour ré-résolution ultérieure.
          example: ChIJDbdkHFQayUwR7-8fITgxTmU
        meta:
          $ref: '#/components/schemas/LeadCaptureMetaAttribution'
        system_id:
          type: string
          format: uuid
          description: |
            Système cible de la capture (UUID d'un Système du compte, visible dans
            le dashboard Capturia). Le lead capturé est engagé dans ce Système.
            Absent → Système principal (comportement historique). Un UUID qui ne
            correspond à aucun Système du compte → erreur `system_not_found` (422),
            aucun contact créé. Un Système du compte mais archivé → repli silencieux
            sur le Système principal.
          example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
    LeadCapturePipelineEcho:
      type: object
      description: |
        Écho du routage pipeline **résolu** : le pipeline demandé (slug → pipeline
        et stage du compte) quand `pipeline.pipeline_slug` a été fourni, sinon le
        pipeline par défaut du compte — celui où un lead capturé sans routage est
        placé. `null` seulement quand aucun slug n'est fourni ET que le compte n'a
        pas de pipeline par défaut. Sur une fiche créée par l'appel
        (`contact_was_new: true`), ce routage est celui appliqué à la fiche. Sur
        une fiche existante retrouvée (`contact_was_new: false`), la fiche
        conserve son pipeline et son étape déjà en place (les champs cœur ne sont
        jamais écrasés) : l'écho reflète alors la demande résolue, pas forcément
        l'état persisté de la fiche.
      required:
        - pipeline_id
        - pipeline_name
      properties:
        pipeline_id:
          type: string
          format: uuid
          description: Identifiant du pipeline ciblé.
        pipeline_name:
          type: string
          description: Nom lisible du pipeline ciblé.
          example: Ventes B2B
        stage_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Identifiant du stage assigné (ou stage par défaut du pipeline).
        stage_name:
          type:
            - string
            - 'null'
          description: Nom lisible du stage assigné.
          example: Nouveau lead
    LeadCaptureResponse:
      type: object
      description: |
        Réponse de `POST /v1/leads/capture`. Le `status` décrit l'issue de la
        **soumission** : enregistrée (`created`), replay idempotent
        (`idempotent_replay` — submission identique reçue dans la fenêtre de
        5 minutes ou via `Idempotency-Key` connu), ou contact déjà pris en charge
        (`existing_pipeline_contact` — déjà dans une étape de pipeline ouverte).
        `contact_was_new` distingue une fiche créée par l'appel d'une fiche
        existante retrouvée par courriel ou téléphone. Dans tous les cas, le
        contact ID retourné est le même.

        Le bot SMS prend le relais dans les 30 secondes après une soumission
        enregistrée si le consentement et la configuration sont en ordre. Il n'est
        jamais déclenché pour un `existing_pipeline_contact`.
      required:
        - status
        - contact_id
        - activity_id
        - sms_conversation_id
        - pipeline
        - original_created_at
        - assignment_status
        - contact_was_new
        - message
      properties:
        status:
          type: string
          description: |
            - `created` — soumission enregistrée. Couvre une fiche créée par
              l'appel **et** une fiche existante retrouvée (même courriel ou même
              téléphone) puis enrichie — `contact_was_new` fait la distinction.
            - `idempotent_replay` — submission déjà traitée (5-min window ou
              Idempotency-Key match), aucun nouveau lead créé
            - `existing_pipeline_contact` — le contact est déjà dans une étape de
              pipeline ouverte (ni gagnée ni perdue), donc déjà pris en charge :
              aucune nouvelle soumission n'est enregistrée et le bot SMS n'est pas
              déclenché. Les champs nouvellement fournis enrichissent quand même le
              contact existant. Permet à une intégration (Zapier, Make, FB Lead Ads)
              de re-pousser un lead sans perturber une conversation en cours.
          enum:
            - created
            - idempotent_replay
            - existing_pipeline_contact
          example: created
        contact_id:
          type: string
          format: uuid
          description: Identifiant du contact créé ou retrouvé.
        contact_was_new:
          type: boolean
          description: |
            `true` seulement quand la fiche contact a été créée par cet appel.
            `false` quand la capture a retrouvé une fiche existante (même courriel
            ou même téléphone, coordonnées secondaires incluses) : la fiche est
            enrichie sans écraser ses champs cœur — son pipeline, son étape et son
            vendeur déjà en place sont conservés, même si la capture en demandait
            d'autres.
        activity_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            Identifiant de l'entrée de journal d'activité créée pour cette capture.
            `null` quand `status = existing_pipeline_contact` : aucune soumission
            n'est enregistrée pour un contact déjà dans une étape de pipeline ouverte.
        sms_conversation_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            Toujours `null` dans la réponse actuelle : le bot SMS démarre en
            asynchrone après la capture, aucune conversation n'existe encore au
            moment où la réponse est produite. Champ conservé pour compatibilité —
            ne pas s'appuyer dessus pour détecter le démarrage du bot.
        pipeline:
          oneOf:
            - $ref: '#/components/schemas/LeadCapturePipelineEcho'
            - type: 'null'
          description: |
            Écho du routage pipeline résolu : le pipeline demandé, sinon le
            pipeline par défaut du compte. `null` quand aucun slug n'est fourni et
            que le compte n'a pas de pipeline par défaut. Sur une fiche existante
            (`contact_was_new: false`), la fiche conserve son pipeline déjà en
            place — voir `LeadCapturePipelineEcho`.
        original_created_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            Pour un `idempotent_replay` : horodatage de la création originale
            (utile pour distinguer un replay imminent d'un replay à plusieurs
            minutes). `null` pour un `created` frais.
        assignment_status:
          type: string
          description: |
            Résultat de la **résolution** du vendeur si
            `pipeline.assigned_sales_rep_email` a été fourni :
            - `not_requested` — aucun email d'assignation fourni
            - `assigned` — le vendeur demandé existe et est actif. Il est assigné
              à une fiche créée par l'appel ; une fiche existante déjà assignée à
              quelqu'un d'autre conserve son vendeur en place (champ cœur non
              écrasé — voir `contact_was_new`).
            - `email_not_found` — email fourni mais aucun membre actif au compte
              activé ne correspond. La capture continue sans assignation : une
              fiche créée par l'appel naît sans vendeur, une fiche existante garde
              son assignation telle quelle.
          enum:
            - not_requested
            - assigned
            - email_not_found
          example: assigned
        message:
          type: string
          description: Message lisible décrivant le résultat (utile pour logs/debug).
          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: |
        Identité du contact visé par l'événement. Au moins un identifiant est
        requis parmi `external_id`, `email`, `phone`. La résolution serveur suit
        l'ordre `external_id`+`external_source` → `phone` → `email` ; les champs
        d'identité fournis enrichissent la fiche de façon non-destructive (une
        valeur déjà présente n'est jamais écrasée).
      properties:
        external_id:
          type: string
          maxLength: 255
          description: |
            Identifiant du contact dans le système source (ex. ID HubSpot, ID
            utilisateur du backend client). Idempotence source : deux événements
            portant le même `external_id` + `external_source` résolvent toujours
            la même fiche.
        external_source:
          type: string
          maxLength: 100
          description: |
            Espace de noms de l'`external_id` (ex. `hubspot`, `shopify`,
            `mon-backend`). Défaut : `api`. Ignoré sans `external_id`.
        email:
          type: string
          format: email
          maxLength: 255
        phone:
          type: string
          description: |
            Numéro accepté aux formats E.164 (`+15145551234`), NANP 10 chiffres,
            11 chiffres avec indicatif, ou variantes formatées — normalisé
            serveur-side vers E.164.
        first_name:
          type: string
          maxLength: 100
        last_name:
          type: string
          maxLength: 100
        create_if_missing:
          type: boolean
          default: true
          description: |
            `false` = ne jamais créer de fiche : si aucun contact ne matche les
            identifiants fournis, la requête répond `404 contact_not_found` et
            rien n'est enregistré.
    EventIngestPayload:
      type: object
      additionalProperties: false
      description: |
        Contrat strict : une clé inconnue au top-level (ou dans `contact`) est
        rejetée en `422 validation_error` — protège contre les typos
        d'intégration silencieuses. Les données libres vont sous `payload`.
      required:
        - event
        - contact
      properties:
        event:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Nom de l'événement, dans l'espace de noms du compte. Comparé tel quel
            (sensible à la casse) au nom configuré sur le déclencheur « Événement
            personnalisé » des automatisations.
          example: abandon_panier
        contact:
          $ref: '#/components/schemas/EventIngestContact'
        payload:
          type: object
          additionalProperties: true
          description: |
            Données arbitraires de l'événement. Chaque clé de premier niveau est
            exposée aux messages des automatisations via `{{event.<clé>}}` (un
            objet imbriqué est sérialisé en JSON). Le contenu persisté est borné
            (nombre de clés, profondeur, taille totale).
        custom_fields:
          type: object
          additionalProperties: true
          description: |
            Champs personnalisés du contact, keyés par le `name` de la définition
            (même contrat que `/v1/leads/capture`). Une clé sans définition
            correspondante est ignorée silencieusement. Merge additif : la
            nouvelle valeur gagne sur collision.
        source_label:
          type: string
          maxLength: 200
          description: Étiquette libre identifiant la source (affichée sur la fiche 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` si une fiche contact a été créée par cet appel.'
        contact_archived:
          type: boolean
          description: |
            `true` si les identifiants ne matchent qu'une fiche archivée :
            l'événement est enregistré sur la fiche (traçabilité) mais la fiche
            n'est pas modifiée et aucune automatisation n'est enrôlée.
        activity_id:
          type: string
          format: uuid
          description: Activité `custom_event_received` enregistrée sur la fiche contact.
        enrolled_executions:
          type: integer
          description: |
            Nombre d'automatisations enrôlées par cet événement. `0` si aucune
            automatisation active n'écoute ce nom d'événement (l'événement est
            quand même enregistré sur la fiche).
        event:
          type: string
        message:
          type: string
    Cursor:
      type:
        - string
        - 'null'
      description: Cursor opaque base64url. Le client ne doit pas parser cette valeur.
      example: eyJ2IjoxLCJmIjoiY3JlYXRlZF9hdCIsInMiOiJkZXNjIiwidmFsIjoiMjAyNi0wNS0wMlQxNDozMDowMFoiLCJpZCI6ImFiYy0xMjMifQ
    Contact:
      type: object
      description: |
        Contact (prospect ou client) appartenant à un client Capturia. Tous les champs
        nullable peuvent être absents en base. `custom_fields` est un objet libre validé
        contre les définitions de champs personnalisés du client.
      required:
        - id
        - email
        - is_priority
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique du contact.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        email:
          type:
            - string
            - 'null'
          format: email
          description: Email principal du contact (lowercase, dédupliqué par client).
          example: alex@example.com
        phone:
          type:
            - string
            - 'null'
          description: Téléphone E.164 (ex `+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 du contact (URL, label, "manual", "api", etc.).
          example: api
        pipeline_stage_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Stage courant dans le pipeline de vente. `null` si le contact n'est pas dans le pipeline.
        deal_value:
          type:
            - number
            - 'null'
          description: Valeur estimée de l'opportunité en CAD.
          example: 2500
        is_priority:
          type: boolean
          description: Marqué prioritaire pour traitement humain accéléré.
          default: false
        custom_fields:
          type:
            - object
            - 'null'
          additionalProperties: true
          description: |
            Champs personnalisés client-définis. Validation au write contre
            `client_custom_field_definitions`. Lecture additive (pas de schéma fixe).
        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: |
            Cursor opaque à passer en `?cursor=` pour la page suivante. `null` si dernière page.
            Format : base64url encodé d'un objet `{v:1, f:string, s:asc|desc, val:string, id:string}`.
            Le client ne doit pas parser cette valeur — la traiter comme opaque.
          example: eyJ2IjoxLCJmIjoiY3JlYXRlZF9hdCIsInMiOiJkZXNjIiwidmFsIjoiMjAyNi0wNS0wMlQxNDozMDowMFoiLCJpZCI6ImFiYy0xMjMifQ
        has_more:
          type: boolean
          description: True s'il existe au moins une page suivante.
        limit:
          type: integer
          minimum: 1
          maximum: 100
          description: Nombre max de résultats par page demandé (defaut 20, max 100).
          example: 20
        cursor:
          type:
            - string
            - 'null'
          deprecated: true
          description: |
            Champ legacy identique à `next_cursor` pendant la fenêtre de transition (sunset 2027-05-03).
            Utiliser `next_cursor` à la place.
        total_count:
          type: integer
          deprecated: true
          description: |
            Champ legacy. Estimation du total — peut diverger du count réel sur grandes tables (PostgreSQL `estimated`).
            Sunset 2027-05-03. Compte exact non garanti et coûteux en perf.
          minimum: 0
    ContactCreate:
      type: object
      description: |
        Body de création d'un contact (`POST /v1/contacts`). Email obligatoire et
        dédupliqué côté serveur — un POST avec un email déjà connu retourne 409
        `duplicate_contact` plutôt qu'un nouvel insert.
      required:
        - email
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
          description: Email du contact. Dédupliqué (lowercase) par `(client_id, email)`.
          example: alex@example.com
        first_name:
          type: string
          maxLength: 100
          example: Alex
        last_name:
          type: string
          maxLength: 100
          example: Tremblay
        phone:
          type: string
          pattern: ^\+[1-9]\d{1,14}$
          description: Téléphone E.164 (ex `+15145551234`).
          example: '+15145551234'
        company:
          type: string
          example: Acme Inc.
        deal_value:
          type: number
          minimum: 0
          description: Valeur estimée de l'opportunité en CAD.
          example: 2500
        custom_fields:
          type:
            - object
            - 'null'
          additionalProperties: true
          description: |
            Champs personnalisés, keyés par le **nom** du champ (ou l'id de sa
            définition). Validés contre `client_custom_field_definitions` à
            l'écriture — un champ inconnu ou un type incompatible retourne
            400 `invalid_custom_fields`. En lecture, les clés retournées sont
            les ids de définition (forme de stockage).
        system_id:
          type: string
          format: uuid
          description: |
            Système cible du contact créé (UUID d'un Système du compte). Le contact
            est engagé dans ce Système. Absent → Système principal (comportement
            historique). UUID inconnu pour ce compte → erreur `system_not_found`
            (422), aucun contact créé ; Système archivé → repli silencieux sur le
            Système principal. Sur `POST /v1/contacts/batch`, ce champ est ignoré
            au niveau item — utiliser le `system_id` top-level du batch, qui
            s'applique à tout le lot.
          example: 3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11
    ContactWithTags:
      description: |
        Contact enrichi avec ses tags (réponse de `GET /v1/contacts/{id}?expand=tags`).
        `client_contact_tags` est la table de jointure et contient le tag complet imbriqué.
      allOf:
        - $ref: '#/components/schemas/Contact'
        - type: object
          properties:
            client_contact_tags:
              type: array
              description: Liens tag↔contact, avec le tag complet imbriqué pour éviter un 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: Couleur hexadécimale du tag (préfixe `#`).
                        example: '#ef4444'
    ContactUpdate:
      type: object
      description: |
        Body de mise à jour d'un contact (`PATCH /v1/contacts/{id}`). Tous les champs
        sont optionnels mais au moins un doit être fourni. `custom_fields` est mergé
        avec les valeurs existantes (pas remplacé).
      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: Téléphone E.164 (ex `+15145551234`).
          example: '+15145551234'
        company:
          type:
            - string
            - 'null'
          example: Acme Inc.
        deal_value:
          type:
            - number
            - 'null'
          minimum: 0
          example: 2500
        is_priority:
          type: boolean
          description: Marqué prioritaire pour traitement humain accéléré.
        notes:
          type:
            - string
            - 'null'
          description: Notes internes libres sur le contact.
        custom_fields:
          type:
            - object
            - 'null'
          additionalProperties: true
          description: |
            Patch additif sur les champs personnalisés existants (les clés non
            fournies sont conservées). Passer `null` efface tous les champs.
    Conversation:
      type: object
      description: |
        Thread de discussion (chat web ou API tierce) appartenant à un client Capturia.
        Backed par la table `chat_sessions` — un thread agrège les messages d'un visiteur
        avec un agent IA ou humain. Les coordonnées (email, phone, first_name, last_name)
        sont snapshotées sur la session ; `contact_id` pointe vers le contact unifié si
        une correspondance a été établie.

        Création et envoi de messages se font côté front-end / SDK chat — l'API publique
        expose la lecture seulement.
      required:
        - id
        - message_count
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de la conversation.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        first_name:
          type:
            - string
            - 'null'
          description: Prénom du visiteur (snapshot à l'ouverture du thread).
          example: Alex
        last_name:
          type:
            - string
            - 'null'
          example: Tremblay
        email:
          type:
            - string
            - 'null'
          format: email
          example: alex@example.com
        phone:
          type:
            - string
            - 'null'
          description: Téléphone du visiteur (E.164 si capturé).
          example: '+15145551234'
        agent_slug:
          type:
            - string
            - 'null'
          description: Slug de l'agent IA qui répond dans cette conversation.
          example: support-ventes
        source:
          type:
            - string
            - 'null'
          description: Source du thread (URL, label de funnel, "widget", etc.).
          example: widget
        message_count:
          type: integer
          minimum: 0
          description: Nombre total de messages dans le thread.
          example: 12
        last_message_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Timestamp ISO 8601 UTC du dernier message. `null` si aucun message.
          example: '2026-05-04T12:34:56.789Z'
        contact_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Contact unifié associé si la correspondance email/phone a été établie.
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    Message:
      type: object
      description: |
        Message individuel d'une conversation (`GET /v1/conversations/{id}/messages`).
        Tri natural reading order (created_at ASC). Le rôle indique l'émetteur.
      required:
        - id
        - role
        - content
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique du message.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        role:
          type: string
          description: |
            Émetteur du message :
            - `user` — visiteur / lead
            - `assistant` — agent IA
            - `system` — message système (init, contexte, instructions)
          enum:
            - user
            - assistant
            - system
          example: user
        content:
          type: string
          description: |
            Contenu textuel du message, prêt à afficher (Markdown léger possible côté
            assistant). Les marqueurs de pilotage internes de l'agent sont retirés :
            un message que l'agent a envoyé en plusieurs bulles est rendu en
            paragraphes séparés. Peut donc être une chaîne vide si le message ne
            portait que du pilotage ; la ligne reste présente pour ne pas trouer la
            pagination.
          example: Bonjour, j'aimerais en savoir plus sur vos services.
        created_at:
          $ref: '#/components/schemas/Timestamp'
    Booking:
      type: object
      description: |
        Rendez-vous (booking) confirmé d'un visiteur sur un projet client. Backed par
        `capturia_bookings`. Chaque booking est rattaché à un `project_id` qui appartient
        obligatoirement au client appelant — l'isolation tenant est appliquée via une
        résolution préalable des projets autorisés.

        L'API publique permet de lister, créer (`status="confirmed"` forcé) et annuler
        (soft cancel : `status="cancelled"` + `cancelled_at`). Aucun PATCH disponible —
        pour modifier un booking il faut l'annuler et en créer un nouveau.
      required:
        - id
        - project_id
        - guest_name
        - guest_email
        - start_datetime
        - end_datetime
        - status
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique du rendez-vous.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        event_type_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Type d'événement (ex consultation, démo) si défini.
        project_id:
          type: string
          format: uuid
          description: Projet client auquel ce rendez-vous appartient. Source d'isolation tenant.
        guest_name:
          type: string
          description: Nom complet du visiteur ayant réservé.
          example: Alex Tremblay
        guest_email:
          type: string
          format: email
          description: Email du visiteur. Pas de validation de format côté serveur — passé tel quel.
          example: alex@example.com
        guest_phone:
          type:
            - string
            - 'null'
          description: Téléphone du visiteur (E.164 si capturé).
          example: '+15145551234'
        start_datetime:
          type: string
          format: date-time
          description: Début du rendez-vous (timestamp ISO 8601 UTC).
          example: '2026-05-10T14:00:00.000Z'
        end_datetime:
          type: string
          format: date-time
          description: Fin du rendez-vous (timestamp ISO 8601 UTC).
          example: '2026-05-10T15:00:00.000Z'
        timezone:
          type:
            - string
            - 'null'
          description: Fuseau horaire IANA d'affichage côté visiteur (ex `America/Montreal`).
          example: America/Montreal
        status:
          type: string
          description: |
            État du rendez-vous :
            - `confirmed` — réservé et actif (valeur par défaut à la création API)
            - `cancelled` — annulé (DELETE force cette valeur + `cancelled_at`)
            - autres valeurs possibles selon les flows internes (UI, calendriers liés)
          example: confirmed
        source:
          type:
            - string
            - 'null'
          description: |
            Origine de la réservation. Forcé à `api` pour les bookings créés via cet
            endpoint. Autres valeurs possibles : `widget`, `manual`, `calendar_sync`, etc.
          example: api
        notes:
          type:
            - string
            - 'null'
          description: Notes libres associées à la réservation.
        meeting_link:
          type:
            - string
            - 'null'
          description: Lien de visioconférence (Zoom, Meet, autre) si fourni.
        video_provider:
          type:
            - string
            - 'null'
          description: Fournisseur de visioconférence (`zoom`, `google_meet`, `manual`, etc.).
        cancelled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Timestamp d'annulation. `null` tant que le booking est actif.
        cancel_reason:
          type:
            - string
            - 'null'
          description: Raison textuelle de l'annulation si fournie.
        assigned_to_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Membre interne assigné au rendez-vous (vendeur, account manager).
        zoom_join_url:
          type:
            - string
            - 'null'
          description: URL Zoom join générée par l'intégration Zoom si activée.
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    BookingCreate:
      type: object
      description: |
        Body de création d'un rendez-vous (`POST /v1/bookings`). Le `project_id` doit
        appartenir au client appelant — sinon 404 `not_found`. Les champs `status` et
        `source` ne sont pas acceptés du caller : forcés à `confirmed` / `api` côté serveur.

        Aucune validation du format ISO 8601 sur `start_datetime`/`end_datetime` —
        transmis tels quels à Postgres qui rejettera un format invalide.
      required:
        - project_id
        - guest_name
        - guest_email
        - start_datetime
        - end_datetime
      additionalProperties: false
      properties:
        project_id:
          type: string
          format: uuid
          description: Projet client cible. Doit appartenir au client appelant.
        guest_name:
          type: string
          description: Nom complet du visiteur.
          example: Alex Tremblay
        guest_email:
          type: string
          format: email
          description: Email du visiteur.
          example: alex@example.com
        start_datetime:
          type: string
          format: date-time
          description: Début du rendez-vous (ISO 8601 UTC recommandé).
          example: '2026-05-10T14:00:00.000Z'
        end_datetime:
          type: string
          format: date-time
          description: Fin du rendez-vous (ISO 8601 UTC recommandé).
          example: '2026-05-10T15:00:00.000Z'
        guest_phone:
          type: string
          description: Téléphone du visiteur (E.164 recommandé).
          example: '+15145551234'
        timezone:
          type: string
          description: Fuseau horaire IANA (ex `America/Montreal`).
          example: America/Montreal
        notes:
          type: string
          description: Notes libres.
        event_type_id:
          type: string
          format: uuid
          description: |
            Type d'événement si applicable. Doit référencer un type d'événement
            actif du catalogue Capturia — un identifiant inconnu ou inactif est
            refusé `404 not_found` sans création.
    BookingCancelResult:
      type: object
      description: |
        Réponse de `DELETE /v1/bookings/{id}` — soft cancel. Le booking n'est pas supprimé
        physiquement : son statut passe à `cancelled` et `cancelled_at` est horodaté.
        Un second DELETE sur un booking déjà annulé retourne 409 `already_cancelled`.
      required:
        - id
        - status
        - cancelled_at
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          description: Toujours `cancelled` après annulation réussie.
          enum:
            - cancelled
        cancelled_at:
          type: string
          format: date-time
          description: Timestamp ISO 8601 UTC de l'annulation.
    Quote:
      type: object
      description: |
        Soumission commerciale (`client_quotes`) destinée à un prospect. Lifecycle :
        `draft` → `sent` → `signed` (terminal) ou `cancelled`. Les quotes archivés
        (`archived_at IS NOT NULL`) sont exclus de toutes les listes et opérations
        PATCH/SEND — l'API publique ne les expose pas.

        Les montants (`subtotal`, `tps_amount`, `tvq_amount`, `total`) sont calculés
        serveur-side à partir des blocks (lignes du devis) — non éditables via PATCH.
        La création API initialise un quote vide (`blocks=[]`) en statut `draft` ; les
        blocks et la finalisation passent par le dashboard.
      required:
        - id
        - client_id
        - quote_number
        - status
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique du devis.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        client_id:
          type: string
          format: uuid
          description: Client Capturia propriétaire du devis.
        quote_number:
          type: string
          description: Numéro de devis lisible (unique par client, attribué par l'appelant).
          example: DEV-2026-0042
        status:
          type: string
          description: |
            Cycle de vie du devis :
            - `draft` — créé, pas encore envoyé
            - `sent` — envoyé au prospect (`sent_at` horodaté)
            - `signed` — signé par le prospect (`signed_at` horodaté, terminal)
            - `cancelled` — annulé (terminal pour l'envoi)
          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: Adresse postale libre du prospect (multilignes acceptées).
        subtotal:
          type:
            - number
            - 'null'
          description: Sous-total avant taxes en CAD. Calculé serveur à partir des blocks.
          example: 1000
        tps_amount:
          type:
            - number
            - 'null'
          description: Montant TPS calculé via le profil de taxes lié.
          example: 50
        tvq_amount:
          type:
            - number
            - 'null'
          description: Montant TVQ calculé via le profil de taxes lié.
          example: 99.75
        total:
          type:
            - number
            - 'null'
          description: Total TTC en CAD (`subtotal + tps_amount + tvq_amount`).
          example: 1149.75
        valid_until:
          type:
            - string
            - 'null'
          format: date
          description: Date limite de validité de l'offre (`YYYY-MM-DD`).
          example: '2026-06-30'
        payment_terms:
          type:
            - string
            - 'null'
          description: Modalités de paiement (texte libre, ex `Net 30`).
        sent_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Horodatage du premier envoi au prospect. `null` tant que `status="draft"`.
        first_viewed_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Première ouverture du devis par le prospect (tracking pixel/page view).
        signed_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Horodatage de signature. `null` tant que `status != "signed"`.
        sales_rep_id:
          type:
            - string
            - 'null'
          format: uuid
          description: Vendeur assigné au devis (membre de l'équipe client).
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    QuoteCreate:
      type: object
      description: |
        Body de création d'un devis (`POST /v1/quotes`). Crée un quote en `status="draft"`
        avec `blocks=[]` (aucune ligne). Pour ajouter des lignes/montants ou envoyer le
        devis au prospect, utiliser le dashboard ou les endpoints dédiés.
      required:
        - quote_number
      additionalProperties: false
      properties:
        quote_number:
          type: string
          description: Numéro lisible du devis (doit être unique par client).
          example: DEV-2026-0042
        prospect_name:
          type: string
          example: Alex Tremblay
        prospect_email:
          type: string
          format: email
          example: alex@example.com
        prospect_phone:
          type: string
          example: '+15145551234'
        prospect_company:
          type: string
          example: Acme Inc.
        prospect_address:
          type: string
          description: Adresse postale libre.
        valid_until:
          type: string
          format: date
          description: Date limite de validité (`YYYY-MM-DD`).
          example: '2026-06-30'
        payment_terms:
          type: string
          description: Modalités de paiement (texte libre).
          example: Net 30
        internal_notes:
          type: string
          description: Notes internes non visibles par le prospect.
        sales_rep_id:
          type: string
          format: uuid
          description: Vendeur assigné.
        tax_profile_id:
          type: string
          format: uuid
          description: Profil de taxes appliqué (TPS/TVQ).
    QuoteUpdate:
      type: object
      description: |
        Body de mise à jour d'un devis (`PATCH /v1/quotes/{id}`). Au moins un champ
        requis sinon 400 `no_fields`. Les montants (`subtotal`, `total`, etc.) ne sont
        pas éditables — calculés serveur à partir des blocks (gérés via dashboard).

        Modifier `status` directement contourne le flow standard `/send` — à utiliser
        avec discernement (ex passer un draft à `cancelled`).
      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
        status:
          type: string
          enum:
            - draft
            - sent
            - viewed
            - cancelled
            - declined
          description: |
            Cycle de vie du devis. Modifier directement `status` court-circuite le flow
            standard `POST /v1/quotes/{id}/send` (qui horodate `sent_at` automatiquement).
            `signed` et `expired` ne sont pas atteignables par PATCH : `signed` exige le
            flux de signature réel (vérification d'identité + certificat), `expired` est
            posé par le cron d'expiration. Valeur hors liste → 400 `validation_error`.
    QuoteSendResult:
      type: object
      description: |
        Réponse de `POST /v1/quotes/{id}/send`. L'API met à jour `status="sent"` et
        `sent_at` mais n'envoie PAS d'email au prospect — la délivrance email passe
        par le dashboard. Un champ top-level `warning` documente cette limitation.

        Échec si le quote est déjà signé (409 `already_signed`) ou annulé (409
        `quote_cancelled`).
      required:
        - data
        - warning
      properties:
        data:
          type: object
          description: |
            Snapshot partiel du quote après mise à jour (sous-ensemble de champs vs
            `Quote` complet — le handler ne resélectionne pas tous les champs).
          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: Toujours `sent` après succès.
            prospect_name:
              type:
                - string
                - 'null'
            prospect_email:
              type:
                - string
                - 'null'
              format: email
            sent_at:
              type: string
              format: date-time
              description: Horodatage de l'envoi (juste défini par la requête).
            created_at:
              $ref: '#/components/schemas/Timestamp'
            updated_at:
              $ref: '#/components/schemas/Timestamp'
        warning:
          type: string
          description: |
            Avertissement intemporel signalant que l'API ne déclenche pas l'envoi email
            — seule la transition de statut est effectuée. Pour envoyer l'email au
            prospect, utiliser le dashboard.
          example: Status updated to sent. Email delivery via API is not yet supported — use the dashboard to send quotes with email notifications.
    Tag:
      type: object
      description: |
        Étiquette (`client_tags`) attachable à un contact ou autre ressource client.
        Listé par ordre alphabétique stable (`name ASC, id ASC`). La couleur est
        libre (hexadécimale par convention) et la catégorie permet de regrouper
        plusieurs tags (ex `priority`, `industry`, `lifecycle`).
      required:
        - id
        - name
        - color
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique du tag.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        name:
          type: string
          description: Nom affiché du tag. Unique recommandé par client (pas enforced).
          example: Lead chaud
        color:
          type: string
          description: |
            Couleur d'affichage. Convention hexadécimale `#RRGGBB`. Défaut serveur
            `#6b7280` (gris neutre) si non fourni à la création.
          example: '#ef4444'
        description:
          type:
            - string
            - 'null'
          description: Description libre (objectif du tag, critères d'application).
        category:
          type:
            - string
            - 'null'
          description: Catégorie de regroupement (ex `priority`, `industry`, `lifecycle`).
          example: priority
        created_at:
          $ref: '#/components/schemas/Timestamp'
    TagCreate:
      type: object
      description: |
        Body de création d'un tag (`POST /v1/tags`). Seul `name` est obligatoire.
        `color` reçoit la valeur par défaut `#6b7280` côté serveur si omis.
      required:
        - name
      additionalProperties: false
      properties:
        name:
          type: string
          description: Nom affiché du tag.
          example: Lead chaud
        color:
          type: string
          description: Couleur hexadécimale `#RRGGBB`. Défaut serveur `#6b7280`.
          example: '#ef4444'
        description:
          type: string
          description: Description libre.
        category:
          type: string
          description: Catégorie de regroupement.
          example: priority
    Automation:
      type: object
      description: |
        Automation client (`client_automations`) — workflow IF/THEN déclenchable par
        événement (lead capturé, stage change, RDV booké, etc.) ou par appel API
        explicite (`POST /v1/automations/{id}/trigger`). Une automation archivée
        (`archived_at IS NOT NULL`) est exclue de la liste publique et ne peut plus
        être triggerée.

        L'API publique expose la lecture (liste + historique d'exécutions) et le
        trigger manuel. La création/édition/archivage d'une automation passe par le
        dashboard — il n'existe pas de 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: Identifiant unique de l'automation.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        name:
          type: string
          description: Nom lisible de l'automation (libellé dashboard).
          example: Relance lead inactif 7 jours
        category:
          type:
            - string
            - 'null'
          description: Catégorie de regroupement libre (ex `nurturing`, `qualification`, `onboarding`).
          example: nurturing
        trigger_type:
          type: string
          description: |
            Type d'événement déclencheur. Valeurs typiques : `lead_captured`,
            `stage_changed`, `booking_created`, `tag_added`, `manual`, `webhook`.
            Liste non-exhaustive — gérée côté dashboard.
          example: lead_captured
        action_type:
          type:
            - string
            - 'null'
          description: |
            Type d'action principale exécutée par l'automation (ex `send_email`,
            `send_sms`, `assign_to_seller`, `create_task`). `null` pour les
            automations multi-actions définies via graphe de noeuds.
          example: send_email
        is_active:
          type: boolean
          description: |
            Si `false`, l'automation est désactivée — les triggers événementiels
            sont ignorés et `POST /v1/automations/{id}/trigger` retourne
            409 `automation_inactive`.
          example: true
        last_executed_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Horodatage ISO 8601 UTC de la dernière exécution (`null` si jamais déclenchée).
          example: '2026-05-04T12:34:56.789Z'
        execution_count:
          type: integer
          minimum: 0
          description: Compteur cumulatif d'exécutions de l'automation.
          example: 42
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    AutomationTriggerInput:
      type: object
      description: |
        Body de déclenchement manuel d'une automation
        (`POST /v1/automations/{id}/trigger`). Crée une exécution `status="pending"`
        avec `trigger_type="manual_api"`, prête à être réclamée par le moteur
        d'automations. L'automation doit être `is_active=true` (sinon
        409 `automation_inactive`), publiée (sinon 409 `automation_not_published`)
        et posséder un noeud déclencheur (sinon 422 `no_trigger`) ; le `contact_id`
        doit appartenir au client appelant (sinon 404 `not_found`).
      required:
        - contact_id
      additionalProperties: false
      properties:
        contact_id:
          type: string
          format: uuid
          description: Contact cible sur lequel exécuter l'automation. Doit appartenir au client appelant.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        event_data:
          type: object
          additionalProperties: true
          description: |
            Contexte d'événement optionnel (objet JSON libre) injecté dans
            l'exécution et exploitable par les variables de template du graphe.
            Vide par défaut.
          example:
            source: crm_import
            campaign: spring-2026
    AutomationTriggerResult:
      type: object
      description: |
        Réponse de `POST /v1/automations/{id}/trigger`. L'API enregistre une
        exécution `pending` (réclamable par le moteur) — le traitement asynchrone
        est pris en charge par le moteur d'automations (pas de garantie de complétion
        synchrone). Pour suivre la progression, interroger
        `GET /v1/automations/{id}/history`.
      required:
        - id
        - automation_id
        - contact_id
        - trigger_type
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de l'exécution créée.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        automation_id:
          type: string
          format: uuid
          description: Automation ciblée (mirror du `{id}` du path).
        contact_id:
          type: string
          format: uuid
          description: Contact ciblé (mirror du body).
        trigger_type:
          type: string
          description: Toujours `manual_api` pour les déclenchements via cet endpoint.
          enum:
            - manual_api
          example: manual_api
        status:
          type: string
          description: Statut initial à la création — toujours `pending` (passera ensuite à `running`/`completed`/`failed`).
          enum:
            - pending
          example: pending
        created_at:
          $ref: '#/components/schemas/Timestamp'
    AutomationHistoryEntry:
      type: object
      description: |
        Entrée d'historique d'exécution d'une automation
        (`client_automation_executions`, exposée via
        `GET /v1/automations/{id}/history`). Une exécution représente un passage
        de l'automation pour un contact donné. Le moteur peut redémarrer une
        exécution échouée (`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: Identifiant unique de l'exécution.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        automation_id:
          type: string
          format: uuid
          description: Automation à laquelle cette exécution se rapporte.
        contact_id:
          type: string
          format: uuid
          description: Contact sur lequel l'automation s'est exécutée.
        trigger_type:
          type: string
          description: |
            Origine du déclenchement : `manual_api` (via cet endpoint),
            `manual_dashboard` (via le dashboard), ou nom de l'événement
            déclencheur (`lead_captured`, `stage_changed`, etc.).
          example: manual_api
        status:
          type: string
          description: |
            État de l'exécution. Valeurs typiques : `pending`, `running`, `paused`,
            `completed`, `failed`, `cancelled`. Liste gérée côté moteur — non-enum
            ici pour ne pas contraindre l'évolution.
          example: completed
        current_node_id:
          type:
            - string
            - 'null'
          description: |
            Identifiant du noeud courant dans le graphe d'exécution (pour les
            automations multi-étapes). `null` pour les automations à action unique.
        scheduled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Horodatage planifié pour l'exécution (utilisé pour les délais et planifications).
        execution_log:
          type:
            - object
            - 'null'
          additionalProperties: true
          description: |
            Journal structuré JSON des étapes parcourues, des décisions prises et
            des erreurs rencontrées. Schéma libre — dépend du type d'automation.
        retry_count:
          type: integer
          minimum: 0
          description: Nombre de tentatives effectuées (0 si succès du premier coup).
          example: 0
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    Deal:
      type: object
      description: |
        Opportunité (deal) du pipeline de vente. Un deal n'est PAS une entité
        distincte en base : c'est un `client_contacts` ayant un
        `pipeline_stage_id` non-NULL. La même ressource peut donc être manipulée
        via `/v1/contacts/{id}` ET `/v1/pipeline/deals/{id}` — l'endpoint deals
        se contente de filtrer/écrire le champ `pipeline_stage_id`.

        Le sous-ensemble de champs exposé est volontairement réduit aux infos
        pertinentes pour la vue pipeline (coordonnées + stage + valeur + score).
        Pour le contact complet (custom_fields, tags, notes), utiliser
        `/v1/contacts/{id}`.

        Endpoints disponibles : `GET /v1/pipeline/deals` (liste filtrable par
        `?stage_id`), `POST /v1/pipeline/deals` (place un contact dans une stage),
        `PATCH /v1/pipeline/deals/{id}` (déplace vers une autre stage),
        `DELETE /v1/pipeline/deals/{id}` (retire du pipeline sans effacer le
        contact). **Pas de `GET /v1/pipeline/deals/{id}` exposé** — récupérer le
        deal individuel via `GET /v1/contacts/{id}` ou via le filtre
        `?stage_id=...` sur la liste.
      required:
        - id
        - pipeline_stage_id
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant du contact/deal (clé partagée avec `client_contacts`).
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        email:
          type:
            - string
            - 'null'
          format: email
          description: Email principal du contact.
          example: alex@example.com
        phone:
          type:
            - string
            - 'null'
          description: Téléphone E.164.
          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: |
            Stage courante du deal dans le pipeline. NOT NULL côté liste deals
            (filtre `pipeline_stage_id IS NOT NULL`). Un DELETE le passe à NULL
            et le contact disparaît alors de la liste deals.
        deal_value:
          type:
            - number
            - 'null'
          description: Valeur estimée de l'opportunité en CAD.
          example: 2500
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
    DealCreate:
      type: object
      description: |
        Body de création d'un deal (`POST /v1/pipeline/deals`). Place un contact
        existant dans une stage du pipeline. **Ne crée pas de contact** — le
        `contact_id` doit déjà exister et appartenir au client appelant (sinon
        404 `not_found`). De même pour `stage_id` (404 `not_found` si la stage
        n'appartient pas au client).

        Si le contact est déjà dans une stage, son `pipeline_stage_id` est écrasé
        par la nouvelle valeur — pas d'historique de mouvement enregistré
        (la table `pipeline_history` référence l'autre table interne `contacts`,
        pas `client_contacts`).
      required:
        - contact_id
        - stage_id
      additionalProperties: false
      properties:
        contact_id:
          type: string
          format: uuid
          description: Contact à placer dans le pipeline. Doit appartenir au client appelant.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        stage_id:
          type: string
          format: uuid
          description: Stage de destination. Doit appartenir au client appelant.
          example: 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
    DealRemoveResult:
      type: object
      description: |
        Réponse de `DELETE /v1/pipeline/deals/{id}`. Le contact n'est PAS supprimé :
        son `pipeline_stage_id` est mis à NULL, ce qui le retire de la liste deals
        sans toucher à l'entité contact (qui reste accessible via `/v1/contacts/{id}`).
      required:
        - id
        - removed_from_pipeline
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant du deal/contact retiré du pipeline.
        removed_from_pipeline:
          type: boolean
          description: Toujours `true` après succès — confirmation que le retrait a bien eu lieu.
          enum:
            - true
          example: true
    DealUpdate:
      type: object
      description: |
        Body de déplacement d'un deal (`PATCH /v1/pipeline/deals/{id}`). Seul le
        déplacement de stage est exposé via cet endpoint — pour modifier les
        autres champs du deal (email, deal_value, etc.) utiliser
        `PATCH /v1/contacts/{id}`.

        `stage_id` est obligatoire (400 `validation_error` sinon). La stage cible
        doit appartenir au client (404 `not_found` sinon). Le deal cible doit déjà
        être dans le pipeline (404 `not_found` si `pipeline_stage_id IS NULL`).
      required:
        - stage_id
      additionalProperties: false
      properties:
        stage_id:
          type: string
          format: uuid
          description: Stage de destination. Doit appartenir au client appelant.
          example: 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
    Stage:
      type: object
      description: |
        Stage du pipeline de vente d'un client (`client_pipeline_stages`). Une
        stage définit une étape du parcours commercial (ex `Nouveau lead`,
        `Qualifié`, `Proposition envoyée`, `Gagné`, `Perdu`). Triées par
        `stage_order ASC`.

        L'API publique expose la lecture seulement
        (`GET /v1/pipeline/stages`) — création/édition/réordonnancement des stages
        passe par le dashboard. La réponse est non-paginée (toutes les stages d'un
        client tiennent généralement sous 20 entrées) mais retourne un format
        compatible `list V2` avec `pagination.next_cursor=null`,
        `pagination.has_more=false` et `pagination.limit` égal au nombre de stages
        retournées.

        Chaque stage est enrichie d'un compteur `contact_count` calculé en temps
        réel — nombre de contacts du client actuellement positionnés sur cette
        stage (utile pour les KPI de pipeline).
      required:
        - id
        - name
        - slug
        - color
        - stage_order
        - is_default
        - is_won
        - is_lost
        - created_at
        - contact_count
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de la stage.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        name:
          type: string
          description: Nom lisible affiché sur le pipeline.
          example: Qualifié
        slug:
          type: string
          description: Slug stable identifiant la stage par convention (utilisé en référence stable).
          example: qualified
        color:
          type: string
          description: Couleur d'affichage hexadécimale `#RRGGBB`.
          example: '#3b82f6'
        stage_order:
          type: integer
          description: Position de la stage dans le pipeline (croissant gauche → droite).
          example: 2
        is_default:
          type: boolean
          description: Stage par défaut où atterrissent les nouveaux contacts (typiquement `Nouveau lead`).
          example: false
        is_won:
          type: boolean
          description: Marque la stage comme terminale "gagnée" (calcul de conversion).
          example: false
        is_lost:
          type: boolean
          description: Marque la stage comme terminale "perdue" (calcul de churn).
          example: false
        probability:
          type:
            - number
            - 'null'
          description: Probabilité de closing associée à la stage (0-100), utilisée pour la pondération du forecast.
          example: 50
        created_at:
          $ref: '#/components/schemas/Timestamp'
        contact_count:
          type: integer
          minimum: 0
          description: |
            Nombre de contacts du client actuellement positionnés sur cette stage.
            Calculé en temps réel à chaque appel — pas mis en cache.
          example: 17
    EmailConversation:
      type: object
      description: |
        Thread email (`client_email_conversations`) entre le client Capturia et un
        contact. Un thread agrège tous les messages échangés sur un même sujet
        avec un même destinataire. Triés par `last_message_at DESC` (plus récent
        en premier).

        L'API publique expose la lecture seulement
        (`GET /v1/emails/conversations`) — l'envoi d'un email passe par
        `POST /v1/emails/send` (qui crée message + conversation côté serveur).
        La consultation des messages individuels d'une conversation n'est pas
        encore exposée via l'API publique (à venir dans une phase ultérieure —
        aujourd'hui le payload se limite aux métadonnées du thread).
      required:
        - id
        - sender_id
        - status
        - unread_count
        - last_message_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de la conversation.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        contact_id:
          type:
            - string
            - 'null'
          format: uuid
          description: |
            Contact unifié associé à cette conversation. `null` si le destinataire
            n'a pas encore été matché à un `client_contacts` (email entrant inconnu
            ou non-encore-réconcilié).
        sender_id:
          type: string
          format: uuid
          description: |
            Membre de l'équipe client (`client_users`) qui a initié ou possède la
            conversation. Détermine la mailbox d'origine côté Resend.
        subject:
          type:
            - string
            - 'null'
          description: Sujet de la conversation (premier message). `null` pour les threads sans sujet renseigné.
          example: Suivi de notre échange
        status:
          type: string
          description: |
            Statut de la conversation côté équipe (lecture/triage). Valeurs
            typiques : `open`, `closed`, `archived`. Liste gérée côté plateforme —
            non-enum ici pour permettre l'évolution.
          example: open
        unread_count:
          type: integer
          minimum: 0
          description: Nombre de messages non-lus dans la conversation côté équipe client.
          example: 2
        last_message_at:
          type: string
          format: date-time
          description: |
            Horodatage ISO 8601 UTC du dernier message (entrant ou sortant).
            Utilisé comme clé de tri principal du thread. NOT NULL côté table.
          example: '2026-05-04T12:34:56.789Z'
        created_at:
          $ref: '#/components/schemas/Timestamp'
    EmailSendPayload:
      type: object
      description: |
        Body d'envoi d'un email transactionnel (`POST /v1/emails/send`). L'email
        est enquêté avec `status="queued"`, `priority=1`, `type="transactional"`
        et délivré par le worker email côté Capturia (Resend) — la réponse 201
        confirme la mise en queue, pas la délivrance finale.

        L'expéditeur (`from`) est résolu serveur-side depuis le sender par
        défaut du client (`client_email_senders.is_default = true`) — pas
        accepté depuis le body. Si aucun sender par défaut n'est configuré :
        400 `no_sender_configured`.

        L'`Idempotency-Key` n'est PAS supporté sur cette route — un retry
        naïf créera un second envoi.
      required:
        - to
        - subject
        - body
      additionalProperties: false
      properties:
        to:
          type: string
          format: email
          description: |
            Destinataire de l'email. Validé contre le pattern email standard
            (`^[^\s@]+@[^\s@]+\.[^\s@]+$`). Un format invalide retourne
            400 `validation_error`.
          example: alex@example.com
        subject:
          type: string
          description: Sujet de l'email (texte libre, pas de cap explicite côté API).
          example: Confirmation de votre commande
        body:
          type: string
          description: |
            Corps de l'email au format HTML. Aucune validation de contenu —
            la sanitization est effectuée par le worker d'envoi avant
            délivrance Resend.
          example: <p>Bonjour Alex,</p><p>Votre commande est confirmée.</p>
        test:
          type: boolean
          default: false
          description: |
            Mode test. Quand `true`, l'envoi est traité comme un test
            opérationnel : les variables de personnalisation (`{{first_name}}`,
            etc.) sont interpolées avec des valeurs d'exemple, le sujet est
            préfixé `[TEST]`, et les vérifications de conformité destinataire
            (désabonnement, consentement) sont contournées.

            Deux garde-fous s'appliquent en contrepartie :

            - le destinataire (`to`) doit être un membre actif de l'équipe du
              client — sinon 400 `recipient_not_allowed` ;
            - un quota de 50 envois de test par 24 h glissantes, partagé avec
              les boutons de test de la plateforme — sinon 429
              `test_quota_exceeded`.

            Un envoi de test ne crée ni conversation ni historique sur un
            contact.
          example: false
    EmailSendResult:
      type: object
      description: |
        Réponse 201 de `POST /v1/emails/send`. L'email est en queue —
        `status="queued"` à la création. La délivrance finale (ouverture, bounce,
        etc.) est tracée séparément côté worker email et n'est pas exposée par
        cet endpoint.
      required:
        - id
        - status
        - to_email
        - subject
        - created_at
        - is_test
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de l'envoi (`client_email_sends.id`).
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        status:
          type: string
          description: Statut initial à la création — toujours `queued`.
          enum:
            - queued
          example: queued
        to_email:
          type: string
          format: email
          description: Destinataire de l'email (mirror du body).
          example: alex@example.com
        subject:
          type: string
          description: |
            Sujet de l'email (mirror du body, préfixé `[TEST]` en mode test).
          example: Confirmation de votre commande
        created_at:
          $ref: '#/components/schemas/Timestamp'
        is_test:
          type: boolean
          description: |
            `true` quand l'envoi a été créé en mode test (champ `test` du body).
          example: false
    Webhook:
      type: object
      description: |
        Webhook sortant — endpoint HTTPS du client appelé par Capturia quand un
        événement souscrit survient. Un webhook créé via l'API appartient à la
        fois au client ET à la clé API qui l'a créé : `GET /v1/webhooks` ne montre
        que les webhooks de la clé courante (ceux créés par une autre clé du même
        client, ou depuis le tableau de bord, ne sont ni visibles ni supprimables
        ici).

        Le secret HMAC utilisé pour signer les payloads sortants est uniquement
        retourné à la création (`POST`) puis chiffré en base. Il ne peut pas être
        relu par la suite — pour le faire tourner, supprimer le webhook et en
        créer un nouveau.
      required:
        - id
        - url
        - events
        - is_active
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Identifiant unique de la souscription.
          example: 8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3
        url:
          type: string
          format: uri
          description: URL HTTPS appelée pour notifier les événements souscrits.
          example: https://hooks.example.com/capturia
        events:
          type: array
          description: Liste des événements souscrits. Voir `WebhookCreate.events` pour les valeurs autorisées.
          items:
            type: string
          example:
            - lead_created
            - payment_received
        is_active:
          type: boolean
          description: |
            Si `false`, la souscription est désactivée (les événements ne sont
            plus livrés à l'URL). La désactivation se fait côté plateforme — pas
            d'endpoint API publique pour basculer ce flag.
          example: true
        created_at:
          $ref: '#/components/schemas/Timestamp'
    WebhookCreate:
      type: object
      description: |
        Body de création d'une souscription webhook (`POST /v1/webhooks`). L'URL
        doit utiliser HTTPS — HTTP est rejeté pour éviter de fuiter les payloads
        en clair. Maximum 10 souscriptions actives par client (au-delà :
        429 `subscription_limit_reached`).
      required:
        - url
        - events
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          description: |
            URL HTTPS qui recevra les notifications. Doit être un endpoint
            accessible publiquement et accepter `POST` JSON.
          example: https://hooks.example.com/capturia
        events:
          type: array
          description: |
            Liste non-vide d'événements métier à souscrire, ou `*` pour tous les
            événements. Le nom de chaque événement est livré tel quel dans l'entête
            `X-Capturia-Event` et le champ `event` du payload. Catalogue complet et
            schéma des payloads : `docs/webhooks-event-catalog.md`.
          minItems: 1
          items:
            type: string
            enum:
              - '*'
              - login
              - logout
              - password_changed
              - invitation_accepted
              - member_invitation_sent
              - member_role_changed
              - member_status_changed
              - member_removed
              - lead_created
              - lead_qualified
              - lead_stage_changed
              - lead_deleted
              - tag_created
              - tag_deleted
              - stage_created
              - stage_deleted
              - booking_created
              - booking_cancelled
              - booking_rescheduled
              - booking_no_show
              - payment_received
              - campaign_created
              - sequence_created
              - automation_created
              - automation_toggled
              - automation_deleted
              - automation_completed
              - quote_created
              - quote_status_changed
              - quote_deleted
              - proposal_sent
              - proposal_viewed
              - proposal_paid
              - contract_sent
              - contract_opened
              - contract_signed
              - contract_revoked
              - integration_connected
              - integration_disconnected
              - integration_status
              - seats_updated
              - domain_added
              - domain_removed
              - permissions_updated
              - agent_updated
          example:
            - lead_created
            - payment_received
    WebhookCreated:
      description: |
        Réponse de `POST /v1/webhooks` — `Webhook` enrichi du `secret` HMAC en
        clair, retourné UNE SEULE FOIS à la création. Le client doit le stocker
        immédiatement : il sert à vérifier la signature des payloads sortants
        (`X-Capturia-Signature`) et n'est plus jamais retourné par l'API.
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required:
            - secret
          properties:
            secret:
              type: string
              description: |
                Secret HMAC en clair (64 caractères hex, 256 bits d'entropie).
                Stocké chiffré côté serveur — non récupérable après cette
                réponse. Utilisé pour signer les payloads sortants.
              example: 8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3
    PresuasionSubmissionAnswer:
      type: object
      description: |
        Réponse formatée à une question de l'outil pré-suasion. Sert à
        construire le bloc humain visible dans la timeline du contact côté
        dashboard Capturia (`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: |
        Body de soumission d'un formulaire pré-suasion
        (`POST /v1/presuasion-submissions`).

        **Convention legacy — endpoint d'intégration interne, ne suit pas les
        conventions v1.** Cet endpoint est appelé par la gateway pré-suasion
        (`demo.capturia.io`) quand un visiteur complète un formulaire d'outil
        pré-suasion. Il n'utilise pas de clé API Bearer (route publique
        rate-limitée par IP), accepte un body au format custom et retourne un
        body au format custom (`{success, contact_id, ...}` au lieu de
        `{data: ...}`).

        Le client cible est résolu serveur-side via `proposition_slug` →
        `presuasion_instances.client_id` (le `client_id` n'est jamais accepté
        depuis le body). Le contact est upserté par `(lower(email), client_id)`
        avec enrichissement additif (les champs existants non-null ne sont
        jamais écrasés).
      required:
        - proposition_slug
        - tool_id
        - tool_name
        - responses
        - formatted_answers
      additionalProperties: false
      properties:
        proposition_slug:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Slug de l'instance pré-suasion (`presuasion_instances.slug`).
            Sert à résoudre le client Capturia cible — le client_id n'est
            jamais lu depuis le body.
          example: blueprint-ventes-2026
        tool_id:
          type: string
          minLength: 1
          maxLength: 200
          description: Identifiant interne de l'outil pré-suasion (catalog côté gateway).
          example: calculateur-roi
        tool_name:
          type: string
          minLength: 1
          maxLength: 200
          description: Nom lisible de l'outil pré-suasion (affiché dans la timeline).
          example: Calculateur de ROI
        email:
          type: string
          format: email
          maxLength: 320
          description: |
            Email du visiteur (clé de dédup côté contact). Obligatoire, SAUF
            quand `identity_confirmed: true` est accompagné d'un `token` valide —
            le contact est alors résolu par le lien tokenisé (règle appliquée
            server-side, non exprimable en JSON Schema : sans email ni
            identité confirmée par token, le serveur répond 400).
          example: alex@example.com
        first_name:
          type: string
          maxLength: 200
          description: Prénom optionnel — enrichit le contact si absent.
          example: Alex
        last_name:
          type: string
          maxLength: 200
          description: Nom de famille optionnel — enrichit le contact si absent.
          example: Tremblay
        phone:
          type: string
          maxLength: 50
          description: Téléphone optionnel — enrichit le contact si absent. Pas de validation de format.
          example: '+15145551234'
        address:
          type: string
          maxLength: 500
          description: |
            Adresse formatée de la propriété (Google Places ou saisie texte). Écrite
            dans la colonne structurée `client_contacts.address` de façon additive :
            ne remplit la colonne que si elle est encore vide (jamais d'écrasement).
          example: 123 Rue Sainte-Catherine, Montréal, QC H2X 1K4, Canada
        postal_code:
          type: string
          maxLength: 20
          description: Code postal extrait par Google Places (Canada). Enrichit `client_contacts.postal_code` si vide.
          example: H2X 1K4
        address_place_id:
          type: string
          maxLength: 300
          description: Google Place ID de l'adresse — deep-link Maps et désambiguïsation. Enrichit `client_contacts.address_place_id` si vide.
          example: ChIJDbdkHFQayUwR7-8fITgxTmU
        responses:
          type: object
          additionalProperties: true
          description: |
            Données brutes du formulaire (paires clé/valeur libres). Stockées
            telles quelles dans `metadata.responses` de l'activité créée.
          example:
            budget: 500-1000
            timeline: 3-mois
        formatted_answers:
          type: array
          minItems: 1
          maxItems: 50
          description: |
            Liste structurée Question/Réponse utilisée pour construire le bloc
            humain visible dans la timeline du contact.
          items:
            $ref: '#/components/schemas/PresuasionSubmissionAnswer'
        result_data:
          type: object
          additionalProperties: true
          description: |
            Données calculées par l'outil pré-suasion (ex score, recommandation,
            catégorie). Stockées telles quelles dans `metadata.result_data`.
        completed_at:
          type: string
          format: date-time
          description: |
            Horodatage de complétion côté gateway. Si absent, le serveur utilise
            `now()`.
          example: '2026-05-04T12:34:56.789Z'
        turnstile_token:
          type: string
          minLength: 1
          maxLength: 2048
          description: |
            Token Cloudflare Turnstile produit par le widget invisible du runner
            de démos (`demo.capturia.io` et domaines custom unifiés). Vérifié
            server-side via siteverify avec `idempotency_key` égal au
            `submission_id` du body (stable entre les retries — un token est à
            usage unique, Cloudflare rejoue le verdict original sur la même clé),
            repli sur le `request_id` de la requête si `submission_id` est absent.
            Optionnel en mode log-only ; obligatoire quand
            `PRESUASION_TURNSTILE_REQUIRED=true` (réponse 403 sinon).
        submission_id:
          type: string
          format: uuid
          description: |
            Clé d'idempotence générée par la gateway, stable à travers les retries
            d'une même soumission. Si une activité `presuasion_submission` existe
            déjà avec ce `submission_id` pour le client résolu, le serveur retourne
            l'activité existante — avant tout décompte de quota et toute écriture —
            au lieu d'en créer une seconde (évite les doublons sur retry
            transitoire, ex. timeout proxy après succès serveur). L'unicité est
            garantie en base par un index unique partiel : deux soumissions
            concurrentes portant le même `submission_id` produisent une seule
            activité.
          example: b3f1c2a4-5d6e-4f70-8a91-2c3d4e5f6071
        token:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Token du lien pré-suasion personnalisé (`presuasion_link_tokens`),
            transmis par la gateway quand le visiteur arrive par un lien envoyé à
            un contact connu. Sert à l'attribution token-first du contact et au
            cycle de vie du lien. Jamais utilisé après un `identity_declined`.
          example: tok_abcdefabcdefabcdefabcdef
        session_key:
          type: string
          format: uuid
          description: |
            Clé anonyme de la visite (`presuasion_tool_sessions.session_key`),
            générée côté runner. Permet de rattacher la soumission à sa visite :
            rejeu du reniement, complétion de la session, et trace
            `metadata.session_id` sur l'activité (cohorte du funnel analytics).
            Absent = soumission sans suivi de visite (lead « non rattaché »).
          example: 6f1f5e0a-2b3c-4d5e-8f9a-0b1c2d3e4f5a
        identity_confirmed:
          type: boolean
          description: |
            Le visiteur a confirmé être le destinataire du lien tokenisé
            (« C'est bien moi »). Avec `token`, le contact est résolu par le lien
            et `email` devient optionnel. Mutuellement exclusif avec
            `identity_declined` (400 sinon).
          example: true
        identity_declined:
          type: boolean
          description: |
            Le visiteur a déclaré ne PAS être le destinataire du lien
            (« Ce n'est pas moi »). Le token n'attribue jamais le contact, le
            reniement est rejoué server-side avant la complétion, et `email`
            redevient obligatoire. Mutuellement exclusif avec
            `identity_confirmed` (400 sinon).
          example: false
        custom_domain_id:
          type: string
          description: |
            Identifiant du custom domain (`client_domains.id`) résolu server-side par
            le proxy gateway à partir du host de la soumission. Sert UNIQUEMENT à
            désambiguïser la proposition quand un même slug actif existe sur plusieurs
            buckets de domaine. Absent = bucket partagé (demo.capturia.io,
            `custom_domain_id IS NULL`). Jamais utilisé pour résoudre le client_id
            (toujours dérivé de la proposition côté serveur).
          example: a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809
    PresuasionSubmissionResponse:
      type: object
      description: |
        Réponse de `POST /v1/presuasion-submissions` (200 OK).

        **Convention legacy** — body custom non aligné avec la convention v1
        `{data: ...}`. Top-level `success` boolean + champs scalaires.
        `contact_created` indique si le contact a été créé (`true`) ou
        seulement enrichi (`false`).
      required:
        - success
        - contact_id
        - contact_created
        - activity_id
      properties:
        success:
          type: boolean
          enum:
            - true
          description: 'Toujours `true` — un échec retourne 4xx/5xx avec un body `{error: "..."}`.'
          example: true
        contact_id:
          type: string
          format: uuid
          description: Identifiant du contact créé ou retrouvé.
        contact_created:
          type: boolean
          description: |
            `true` si un nouveau contact a été créé, `false` si un contact
            existant a été enrichi (les champs absents ont été remplis, les
            champs déjà présents n'ont jamais été écrasés).
          example: false
        activity_id:
          type: string
          format: uuid
          description: |
            Identifiant de l'activité de type `presuasion_submission` créée
            dans la timeline du contact.
    PresuasionSubmissionError:
      type: object
      description: |
        Body d'erreur des réponses 4xx/5xx de `POST /v1/presuasion-submissions`.

        **Convention legacy** — body custom non aligné avec
        `ErrorEnvelope` du catalog v1. `error` est une chaîne lisible
        (pas d'objet `{code, message, ...}`) et `issues` n'est présent que
        pour les erreurs de validation Zod (400 `Invalid payload`).
      required:
        - error
      properties:
        error:
          type: string
          description: Message d'erreur lisible.
          example: Invalid payload
        issues:
          type: array
          description: Détails par champ pour les erreurs de validation Zod (400 uniquement).
          items:
            type: object
            required:
              - path
              - message
            properties:
              path:
                type: string
                description: Chemin du champ fautif (notation pointée).
                example: formatted_answers.0.question
              message:
                type: string
                description: Message Zod expliquant l'erreur.
                example: String must contain at least 1 character(s)
  responses:
    InvalidApiKey:
      description: Clé API inconnue ou format invalide.
      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: Le quota de requêtes par fenêtre est dépassé pour cette clé API ou cet 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: Erreur serveur inattendue. Toujours fournir le `request_id` au 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: La requête est mal formée (header manquant, JSON invalide, paramètre invalide).
      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
    InsufficientScope:
      description: 'Refus d''autorisation. Deux causes, distinguées par `code` : `insufficient_scope` (la clé ne possède pas le scope requis — `details.required_scope`) ou `feature_not_available` (l''API publique ou le module visé n''est pas inclus dans le forfait du compte — `details.feature` nomme la fonctionnalité, ex. `public_api`, `quotes`). Un refus de forfait est définitif pour la clé : inutile de réessayer, le compte doit changer de forfait.'
      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
    IdempotencyConflict:
      description: |
        Une requête précédente avec la même `Idempotency-Key` a utilisé un payload différent,
        ou est encore en cours de traitement.
      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
    ResourceNotFound:
      description: La ressource demandée n'existe pas (ou pas dans le scope du tenant).
      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: Validation des paramètres ou du body a échoué.
      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
    DuplicateResource:
      description: Une ressource avec un identifiant unique conflictuel existe déjà.
      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
    InvalidContentType:
      description: Le header `Content-Type` est manquant ou non supporté pour cet 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: La requête est syntaxiquement valide mais viole une règle métier.
      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
  parameters:
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: ^[A-Za-z0-9_\-]+$
      description: |
        Clé d'idempotence (UUID v4 ou ULID recommandé). Une 2e requête avec la même clé et le même payload
        renvoie la response d'origine sans dupliquer l'opération. Stockée 24h. Optionnel mais recommandé.
    CursorParam:
      name: cursor
      in: query
      required: false
      schema:
        $ref: '#/components/schemas/Cursor'
      description: Cursor opaque pour pagination — passer la valeur de `pagination.next_cursor` de la page précédente.
    LimitParam:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      description: Nombre max de résultats par page (defaut 20, max 100). Au-delà → 400 invalid_request.
    CreatedAfterParam:
      name: created_after
      in: query
      required: false
      schema:
        $ref: '#/components/schemas/Timestamp'
      description: Filtre — uniquement les ressources créées après cette date (inclusive).
    CreatedBeforeParam:
      name: created_before
      in: query
      required: false
      schema:
        $ref: '#/components/schemas/Timestamp'
      description: Filtre — uniquement les ressources créées avant cette date (inclusive).
    ResourceIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: UUID de la ressource.
