{
  "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,\ncode custom, CRMs externes) via des endpoints REST authentifiés par clé API.\n\n## Démarrage rapide\n\n1. Créer une clé API dans le dashboard : `Admin → Intégrations → Clés API`\n2. Choisir le preset adapté (`Capture de leads` pour pousser des leads depuis un funnel)\n3. Tester avec `GET /v1/me` pour vérifier que la clé fonctionne\n4. Capturer un lead : `POST /v1/leads/capture`\n\nDocumentation complète : https://capturia.io/fr/developers\n",
    "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\n(nom, scopes, preset inféré, expiration, rate limits, client). C'est l'outil le plus\nsimple pour confirmer qu'une intégration tierce est connectée. Aucune mutation,\naucun side-effect — sécurisé à appeler depuis n'importe quel script de smoke test.\n\nAucun scope spécifique n'est requis ; toute clé authentifiée et active peut appeler\ncet endpoint. Consomme 1 unité du rate limit per-key (pour éviter qu'un script en\nboucle ne sature le tracking).\n",
        "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\nla clé authentifiée sur une plage temporelle donnée, plus les totals calculés\napplication-side. Utile pour dimensionner sa consommation, monitorer les latences\net détecter les rejets rate limit.\n\nAucun scope spécifique requis (pattern GitHub `/rate_limit`) : consulter ses\npropres metrics est gratuit et toujours permis pour toute clé authentifiée.\n\nLa plage `(to - from)` doit être strictement positive et ne pas dépasser\n**90 jours**. Les buckets retournés sont triés par `bucket_start ASC` ; les\nfenêtres sans trafic sont absentes du tableau (pas de zéro-padding).\n",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "Borne inférieure (inclusive) de la plage temporelle, ISO 8601 UTC.\n",
            "schema": {
              "$ref": "#/components/schemas/Timestamp"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "Borne supérieure (exclusive) de la plage temporelle, ISO 8601 UTC. Doit\nêtre strictement après `from` et la plage `(to - from)` ≤ 90 jours.\n",
            "schema": {
              "$ref": "#/components/schemas/Timestamp"
            }
          },
          {
            "name": "granularity",
            "in": "query",
            "required": false,
            "description": "Granularité des buckets retournés. `day` par défaut.\n",
            "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\ntiers (Make, Zapier, n8n, landing custom). Une fois capturé, le lead apparaît\nimmédiatement dans **Mes Prospects** côté dashboard et le bot SMS prend le\nrelais dans les 30 secondes si le consentement et la configuration sont en\nordre.\n\n### Contact déjà connu (courriel ou téléphone)\nLa fiche est résolue par **courriel d'abord** (insensible à la casse),\npuis par **téléphone**, en incluant les coordonnées secondaires déjà\nrattachées à une fiche. Capturer un lead dont le courriel ou le\ntéléphone existe déjà **réutilise et enrichit la fiche existante** — le\n`contact_id` retourné est celui de cette fiche, jamais un doublon et\njamais une erreur. Les coordonnées et champs cœur déjà remplis (nom,\ncourriel, téléphone, pipeline, valeur, adresse...) ne sont pas\nécrasés ; les réponses de qualification (`custom_fields`) sont en\nrevanche rafraîchies avec les dernières valeurs envoyées — une clé\ndéjà présente est remplacée, une clé nouvelle s'ajoute. Un nouveau\ntéléphone est conservé comme numéro secondaire quand la fiche en a\ndéjà un. Si le courriel et le téléphone pointent deux fiches\ndifférentes, le courriel a priorité et aucune coordonnée n'est retirée\nà l'autre fiche.\n\nLa réponse expose `contact_was_new` pour distinguer les deux issues :\nsur une fiche existante (`false`), `pipeline` et `assignment_status`\ndécrivent la demande résolue, pas forcément l'état persisté — la fiche\nconserve son pipeline, son étape et son vendeur déjà en place.\n\n### Contact déjà en pipeline ouvert\nUn lead dont le contact (résolu par courriel ou téléphone) est déjà dans\nune étape de pipeline ouverte (déjà pris en charge par un vendeur — non\narchivé, étape ni gagnée ni perdue)\nn'est pas re-soumis : la réponse renvoie `status: existing_pipeline_contact`,\naucune nouvelle soumission n'est enregistrée et le bot SMS n'est pas\nredéclenché, pour ne pas écraser une conversation en cours. Les champs\ncœur nouvellement fournis enrichissent quand même le contact existant.\n\nLa dédup de retry reste prioritaire : un re-push reçu dans la fenêtre de\n5 minutes est traité comme un retry (`status: idempotent_replay`, voir\nIdempotency) et conserve l'`activity_id` et l'`original_created_at`\nd'origine — `existing_pipeline_contact` ne s'applique qu'au-delà de cette\nfenêtre.\n\n### Idempotency\nLe header `Idempotency-Key` est **optionnel mais recommandé** pour les retries\ncôté client. Une 2e requête avec la même clé et le même payload renvoie la\nréponse d'origine sans dupliquer le lead. La clé est stockée 24h. Une 2e\nrequête avec la même clé mais un payload différent retourne 409\n`idempotency_conflict`. La RPC sous-jacente conserve par ailleurs sa propre\nfenêtre de dedup à 5 minutes. Cette fenêtre est **par contact et\nindépendante du contenu** : toute soumission d'un contact déjà capturé\ndans les 5 dernières minutes est traitée comme un retry\n(`idempotent_replay`) même si le payload diffère — la fiche est tout de\nmême enrichie (dont les `custom_fields`, rafraîchis), mais aucune\nnouvelle activité, aucun tag, et pas de relance du bot SMS. Les mises à\njour de la fiche restent réelles : une automatisation qui surveille un\nchamp personnalisé réagit au changement de valeur, comme pour toute\nmodification de la fiche. C'est aussi ce qui protège d'une double\nrelance du bot quand un lead re-soumet son formulaire coup sur coup.\nUn `Idempotency-Key` différent ne contourne pas cette fenêtre : elle\ns'applique en base après tout cache miss. Deux soumissions\nvolontairement distinctes du même contact ne produisent deux activités\nque si elles sont espacées de plus de 5 minutes.\n\n### Codes d'erreur custom\nPour des raisons historiques, certains rejets retournent des codes hors\ncatalog standard :\n\n- **422** — `invalid_phone`, `missing_consent`, `invalid_email`,\n  `invalid_payload`, `pipeline_not_found`, `stage_not_found` (au lieu de\n  `validation_error` / `business_rule_violation`). Body shape simplifiée\n  propre à cette route — voir la réponse 422 ci-dessous.\n- **429** — `rate_limited_ip` quand la limite IP (100 req/min) est atteinte\n  (au lieu de `rate_limit_exceeded`). Le header `X-RateLimit-Scope: ip`\n  identifie le scope de la limite déclenchée. Body shape simplifiée (pas de\n  `request_id` ni `details` standard) — à harmoniser dans une phase\n  ultérieure.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "leads:capture"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadCapturePayload"
              },
              "example": {
                "phone": "+15145551234",
                "sms_consent": true,
                "first_name": "Alex",
                "last_name": "Tremblay",
                "email": "alex@example.com",
                "source_label": "funnel-fb-jan",
                "utm": {
                  "source": "facebook",
                  "medium": "cpc",
                  "campaign": "spring-2026"
                },
                "tags": [
                  "lead-chaud",
                  "vu-webinaire"
                ],
                "pipeline": {
                  "pipeline_slug": "ventes-b2b",
                  "stage_slug": "nouveau-lead",
                  "deal_value": 2500,
                  "expected_close_date": "2026-06-30",
                  "assigned_sales_rep_email": "vendeur@example.com"
                },
                "custom_fields": {
                  "type_propriete": "maison_isolee",
                  "delai_vente": "0_3_mois"
                },
                "address": "123 rue Principale, Montréal",
                "postal_code": "H2X 1Y4"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lead capturé (`status: created`), replay idempotent d'une capture\nprécédente (`status: idempotent_replay`), ou contact déjà pris en\ncharge (`status: existing_pipeline_contact` — déjà dans une étape de\npipeline ouverte : aucune nouvelle soumission n'est enregistrée et le\nbot SMS n'est pas déclenché, `activity_id` est `null`). Dans tous les\ncas, le `contact_id` retourné est stable.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadCaptureResponse"
                },
                "example": {
                  "status": "created",
                  "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                  "activity_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
                  "sms_conversation_id": null,
                  "pipeline": {
                    "pipeline_id": "3d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f80",
                    "pipeline_name": "Ventes B2B",
                    "stage_id": "4e5f6a7b-8c9d-0e1f-2a3b-4c5d6e7f8091",
                    "stage_name": "Nouveau lead"
                  },
                  "original_created_at": null,
                  "assignment_status": "assigned",
                  "contact_was_new": true,
                  "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`,\n`invalid_email`, `invalid_payload`) ou par la résolution du pipeline\n(`pipeline_not_found`, `stage_not_found`). Forme simplifiée propre à\ncette route : pas de `type` ni de `doc_url`, et `details` est un\ntableau `[{ field, reason }]` (`field` vide quand le body n'est pas\nun objet).\nPour un champ scalaire fautif, le `message` renvoie en écho la\nvaleur reçue (bornée à 120 caractères) — elle n'est jamais conservée\ncôté Capturia ; seuls les noms des champs reçus le sont, visibles\ndans l'onglet Erreurs récentes de la clé API. Le `request_id` est\ntoujours dans l'en-tête `X-Request-ID`.\n",
            "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,\npréfixée de `Received <valeur> for field \"<champ>\" —`.\n"
                        },
                        "request_id": {
                          "type": "string",
                          "pattern": "^req_[0-9A-HJKMNP-TV-Z]{26}$"
                        },
                        "details": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "field",
                              "reason"
                            ],
                            "properties": {
                              "field": {
                                "type": "string",
                                "description": "Chemin du champ fautif (`phone`,\n`pipeline.stage_slug`, `custom_fields.ma_cle`).\n"
                              },
                              "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,\nn8n, un backend client, un outil tiers) pousse un événement nommé avec\nses données. Capturia résout le contact (`external_id` → `phone` →\n`email`), enrichit sa fiche de façon non-destructive, remplit les champs\npersonnalisés mappés, enregistre l'événement sur la fiche, puis démarre\nles automatisations dont le déclencheur « Événement personnalisé »\nécoute ce nom d'événement — avec le `payload` disponible dans les\nmessages via `{{event.<clé>}}`.\n\n### Résolution du contact\nAu moins un identifiant est requis (`external_id`, `email` ou `phone`).\nSi aucun contact ne matche, une fiche minimale est créée — sauf si\n`contact.create_if_missing` est `false`, auquel cas la requête répond\n`404 contact_not_found` sans rien enregistrer. Si les identifiants ne\nmatchent qu'une fiche **archivée**, l'événement est enregistré dessus\n(traçabilité) mais la fiche n'est pas modifiée et aucune automatisation\nn'est enrôlée (`contact_archived: true`).\n\n### Consentement (Loi 25 / LCAP)\nLa réception d'un événement n'établit **aucun** consentement de\ncommunication. Un contact créé par ce chemin ne peut pas recevoir de\nSMS/courriel tant qu'un consentement n'est pas capté par un autre flux ;\nune automatisation déclenchée qui tenterait un envoi sans consentement\nest bloquée par les gardes du moteur d'exécution.\n\n### Signature HMAC optionnelle\nEn plus de la clé API, la requête peut porter une signature\n`X-Capturia-Signature: t=<unix>,v1=<hex>` — HMAC-SHA256 de\n`${t}.${corps brut}` calculée avec **la clé API brute comme secret**,\nfenêtre anti-replay de 5 minutes. Dès que le header est présent, la\nvalidation est stricte (fail-closed) : signature invalide = `401\ninvalid_signature`. Sans le header, la clé API seule authentifie.\n\n### Idempotency\nLe header `Idempotency-Key` est optionnel mais recommandé pour les\nretries : une 2e requête avec la même clé et le même payload renvoie la\nréponse d'origine sans ré-ingérer l'événement (stockage 24h ; payload\ndifférent = `409 idempotency_conflict`). Par ailleurs, l'enrôlement des\nautomatisations porte sa propre dédup de 5 minutes par contact et par\nautomatisation.\n\n### Codes d'erreur custom\n- **429** — `rate_limited_ip` quand la limite IP (120 req/min) est\n  atteinte, header `X-RateLimit-Scope: ip`. Limite par clé : 60 req/min\n  (modulable par clé).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "events:ingest"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EventIngestPayload"
              },
              "example": {
                "event": "abandon_panier",
                "contact": {
                  "external_id": "user-4821",
                  "external_source": "mon-backend",
                  "email": "alex@example.com",
                  "first_name": "Alex"
                },
                "payload": {
                  "montant": 249.99,
                  "produit": "Forfait Pro",
                  "url_panier": "https://boutique.example.com/panier/abc123"
                },
                "custom_fields": {
                  "plan_vise": "pro"
                },
                "source_label": "Boutique en ligne"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Événement ingéré (`status: ingested`). `enrolled_executions` indique\ncombien d'automatisations ont démarré ; `0` signifie qu'aucune\nautomatisation active n'écoute ce nom d'événement — l'événement est\nquand même enregistré sur la fiche contact.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventIngestResponse"
                },
                "example": {
                  "status": "ingested",
                  "contact_id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
                  "contact_created": false,
                  "contact_archived": false,
                  "activity_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
                  "enrolled_executions": 1,
                  "event": "abandon_panier",
                  "message": "Event ingested. 1 automation(s) enrolled."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts": {
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "listContacts",
        "summary": "Lister les contacts",
        "description": "Retourne la liste paginée des contacts du client (cursor v2).\nTri stable `created_at DESC, id DESC`. Supporte recherche\ncase-insensitive sur `email`, `first_name`, `last_name`, `company`,\nfiltre par tag, et inclusion conditionnelle des définitions de\ncustom fields dans `meta`.\n\n### Recherche\n\nLe paramètre `?search` est sanitizé côté serveur — les caractères\n`%`, `_`, `,`, `(`, `)`, `.`, `*`, `\\` sont retirés avant l'application\ndu filtre `ilike` pour empêcher l'injection PostgREST.\n\n### Inclusion custom fields\n\nPasser `?include=custom_field_definitions` ajoute le champ\n`meta.custom_field_definitions` à la réponse — utile pour rendre\nun formulaire d'édition côté intégrateur sans 2e round-trip.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "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\ndédupliqué — si un contact avec ce mail existe déjà sous ce client,\nla réponse est `409 duplicate_resource` (le contact existant n'est\npas écrasé).\n\n### Idempotency\n\nLe header `Idempotency-Key` est **optionnel mais recommandé**.\nUne 2e requête avec la même clé et le même payload renvoie la\nréponse d'origine sans dupliquer l'opération. Une 2e requête avec\nla même clé et un payload différent retourne `409 idempotency_conflict`.\n\n### Custom fields\n\nLes clés de `custom_fields` sont le **nom** du champ tel que défini\ndans Capturia (ou, de façon équivalente, l'id de sa définition) —\nmême contrat que `/v1/events` et `/v1/leads/capture`. Les valeurs\nsont validées contre les définitions du client\n(`client_custom_field_definitions`). Un champ inconnu ou un type\nincompatible retourne `400 invalid_custom_fields` avec `details`\nlistant les violations.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactCreate"
              },
              "example": {
                "email": "alex@example.com",
                "first_name": "Alex",
                "last_name": "Tremblay",
                "phone": "+15145551234",
                "company": "Acme Inc.",
                "deal_value": 2500
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Contact 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\ntable `client_contact_tags` pour inclure les tags rattachés (id, nom,\ncouleur) sans round-trip supplémentaire.\n",
        "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é.\n`ContactWithTags` étend `Contact` via `allOf` — un payload\nsans tags satisfait les deux schémas (d'où `anyOf`).\n"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "email": "alex@example.com",
                    "phone": "+15145551234",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "company": "Acme Inc.",
                    "source": "api",
                    "pipeline_stage_id": null,
                    "deal_value": 2500,
                    "is_priority": false,
                    "custom_fields": null,
                    "created_at": "2026-04-01T10:00:00.000Z",
                    "updated_at": "2026-04-01T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "404": {
            "$ref": "#/components/responses/ResourceNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Contacts"
        ],
        "operationId": "updateContact",
        "summary": "Mettre à jour un contact",
        "description": "Met à jour les champs fournis du contact (champs absents conservés).\nAu moins un champ doit être fourni sinon `400 no_fields`.\n\nLe champ `custom_fields` est **mergé** avec les valeurs existantes\n(les clés non fournies sont conservées). Les clés sont le **nom** du\nchamp (ou l'id de sa définition) ; une clé inconnue retourne\n`400 invalid_custom_fields`. Passer `null` efface tous les champs\npersonnalisés.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactUpdate"
              },
              "example": {
                "first_name": "Alexandre",
                "deal_value": 5000,
                "is_priority": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Contact 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\nde bord : sa conversation d'inbox, ses échanges (SMS, courriels,\ntranscriptions de chat), ses rendez-vous et ses fichiers partent avec\nlui. Les ventes, commandes et paiements sont conservés, simplement\ndétachés de la fiche.\n\nLe retrait hors plateforme (rendez-vous au calendrier Google connecté,\nfichiers stockés) est fait dans la foulée, en best-effort : la\nsuppression du contact en base réussit même si un service externe est\nmomentanément injoignable. L'objet `cleanup` de la réponse l'indique —\n`failed` > 0 signifie que des rendez-vous Google n'ont pas pu être\nretirés du calendrier (compte déconnecté, panne) et y restent.\n",
        "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,\nenvois SMS/email, RDV, etc.) en ordre chronologique inverse.\n\n### Limitation actuelle\n\nL'implémentation est un stub : la table `pipeline_history` référence\nl'autre table `contacts` interne (et non `client_contacts`), donc\nla liste retournée est **toujours vide** aujourd'hui. La pagination\nest validée mais non appliquée. À implémenter dans une phase\nultérieure quand l'historique sera unifié sur `client_contacts`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "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\n`client_contact_tags`. Les tags doivent appartenir au même client\nque le contact — sinon `400 validation_error` avec la liste des\ntags non trouvés.\n\nL'opération est idempotente côté DB : appeler 2 fois avec les\nmêmes `tag_ids` ne crée pas de doublons (upsert\n`ON CONFLICT (contact_id, tag_id) DO NOTHING`).\n\n### Lister les tags d'un contact\n\nIl n'existe pas de `GET` dédié — utiliser\n`GET /v1/contacts/{id}?expand=tags` pour récupérer les tags\nactuellement rattachés.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tag_ids"
                ],
                "additionalProperties": false,
                "properties": {
                  "tag_ids": {
                    "type": "array",
                    "minItems": 1,
                    "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\nn'est pas supprimé — il reste disponible pour d'autres contacts.\n\nSi le tag n'est pas attaché au contact (ou si le contact appartient\nà un autre client), retourne `404 resource_not_found`.\n",
        "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\ncontacts dont l'email existe déjà sous le client sont **mis à jour**\n(les autres champs fournis remplacent les valeurs existantes), les\nautres sont **insérés**.\n\n### Déduplication interne\n\nLe payload est dédupliqué en interne par `lower(email)` (last-entry\nwins) avant l'upsert. L'ordre des contacts dans le tableau et la\ncasse de l'email n'affectent pas le hash idempotency.\n\n### Idempotency\n\nLe header `Idempotency-Key` est **optionnel mais recommandé**. Le\nhash idempotency porte sur le payload **après dédup interne** —\nretry réseau idempotent même si le client renvoie les contacts dans\nun ordre différent ou avec une casse email différente.\n\n### Réponse\n\nL'endpoint retourne `200` avec `{data: {processed, contacts: []}}`\n— pas de multi-status par contact. Une erreur de validation sur un\nseul contact rejette l'ensemble du batch (`400 validation_error`\navec `details` listant chaque ligne fautive). Une erreur DB en\ncours d'upsert retourne `500` — les contacts déjà persistés\navant l'erreur restent (pas de rollback transactionnel\ncross-statements).\n\nQuand `system_id` est fourni et résolu vers un Système actif, la\nréponse inclut aussi `system_routing` : le bilan du routage des\ncontacts **créés** vers ce Système. Le routage est best-effort\n(l'upsert est déjà commité quand il tourne) — `refused + errors` =\ncontacts créés restés dans le Système principal.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "contacts:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKeyHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contacts"
                ],
                "additionalProperties": false,
                "properties": {
                  "contacts": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "description": "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\ncontacts **créés** par ce batch sont engagés dans ce\nSystème ; les contacts mis à jour gardent leur engagement.\nAbsent → Système principal (comportement historique). UUID\ninconnu pour ce compte → `system_not_found` (422), aucun\ncontact écrit ; Système archivé → repli silencieux sur le\nSystème principal.\n",
                    "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
                  },
                  "trigger_automations": {
                    "type": "boolean",
                    "default": false,
                    "description": "Déclenche les automatisations et séquences actives du\ncompte pour les contacts de ce lot (contact créé, tags\najoutés). **Défaut `false` : rien ne se déclenche** —\naucun enrôlement d'automatisation ni de séquence, aucun\ncourriel ni SMS causé par cet import. À `true`, la réponse\nremonte `automations_enrolled` et, si le plafond horaire\ndu moteur (200 enrôlements/compte/heure) est atteint,\n`automations_skipped_rate_limit` — jamais d'abandon\nsilencieux.\n"
                  }
                }
              },
              "example": {
                "contacts": [
                  {
                    "email": "alex@example.com",
                    "first_name": "Alex",
                    "last_name": "Tremblay",
                    "phone": "+15145551234",
                    "company": "Acme Inc.",
                    "deal_value": 2500
                  },
                  {
                    "email": "morgan@example.com",
                    "first_name": "Morgan",
                    "last_name": "Bélanger",
                    "company": "Example Inc."
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Batch 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.\nToujours `0` quand `trigger_automations` est absent\nou `false`.\n"
                        },
                        "automations_skipped_rate_limit": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Enrôlements refusés parce que le plafond horaire du\nmoteur d'automatisations (200 enrôlements/compte/\nheure) était atteint. `> 0` = une partie du lot n'est\npas entrée dans les automatisations — ré-importer ces\ncontacts plus tard ou les enrôler manuellement.\n"
                        },
                        "sequences_enrolled": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Enrôlements de séquences courriel créés par ce lot.\nToujours `0` quand `trigger_automations` est absent\nou `false`.\n"
                        },
                        "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\nSystème cible. Présent seulement quand `system_id` a\nété fourni et résolu vers un Système actif. Le\nroutage est best-effort : `refused + errors` =\ncontacts créés restés dans le Système principal.\n",
                          "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\nclient (cursor v2). Tri stable `created_at DESC, id DESC`.\n\nUne conversation agrège les messages d'un visiteur avec un agent\n(IA ou humain) sous le client appelant. La création de threads et\nl'envoi de messages se font côté front-end / SDK chat — l'API\npublique expose la lecture seule.\n\n### Filtres\n\n- `?agent_slug` — filtre exact sur l'agent IA répondant dans le thread.\n- `?source` — filtre exact sur la source du thread (URL, label de\n  funnel, `widget`, etc.).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "conversations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "agent_slug",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "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\nrécupérer les messages individuels, utiliser\n`GET /v1/conversations/{id}/messages`.\n\nSi la conversation appartient à un autre client (ou n'existe pas),\nretourne `404 resource_not_found` — l'isolation tenant masque\nl'existence des conversations hors du client appelant.\n",
        "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\n**ordre chronologique de lecture** (`created_at ASC, id ASC`),\navec un tiebreaker `id` pour éviter les doublons ou skips entre\ndeux pages quand plusieurs messages partagent la même milliseconde.\n\nCette direction de tri (ASC) est inversée par rapport aux autres\nlist endpoints v2 (DESC) — la lecture naturelle d'une conversation\nsuit l'ordre chronologique des échanges.\n\nSi la conversation appartient à un autre client (ou n'existe pas),\nretourne `404 resource_not_found`.\n",
        "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\n`start_datetime DESC, id DESC` — les rendez-vous à venir apparaissent\nd'abord, puis l'historique.\n\nL'isolation tenant est appliquée via une résolution préalable des\nprojets autorisés : si le client n'a aucun projet, la liste retournée\nest vide (pas d'erreur).\n\n### Filtres\n\n- `?from` (ISO 8601) — `start_datetime >= from`\n- `?to` (ISO 8601) — `start_datetime <= to`\n- `?status` — filtre exact (`confirmed`, `cancelled`, etc.)\n- `?project_id` — filtre exact ; un `project_id` qui n'appartient pas\n  au client retourne une liste vide (pas une erreur).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "bookings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "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\nappartenir au client appelant — sinon `404 resource_not_found`.\nS'il est fourni, `event_type_id` doit référencer un type d'événement\nactif du catalogue Capturia — sinon `404 resource_not_found`, sans\ncréation.\n\nLes champs `status` et `source` ne sont pas acceptés du caller :\nforcés à `confirmed` / `api` côté serveur. Pour annuler un\nrendez-vous, utiliser `DELETE /v1/bookings/{id}` (soft cancel).\n\n### Format des dates\n\nAucune validation côté serveur du format ISO 8601 sur\n`start_datetime` / `end_datetime` — les valeurs sont transmises\ntelles quelles à Postgres qui rejettera un format invalide\n(`500 internal_error`). Préférer ISO 8601 UTC (`...Z`) pour éviter\nles ambiguïtés de fuseau.\n",
        "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 :\nson `status` passe à `cancelled` et `cancelled_at` est horodaté.\nLa réponse `200` retourne ces 3 champs (et non `204 No Content`).\n\nUn second `DELETE` sur un rendez-vous déjà annulé retourne\n`409 already_cancelled` — l'opération n'est pas idempotente côté\nstatut HTTP.\n\nPour modifier un rendez-vous existant (changement d'horaire, de\nnotes), il faut l'annuler et en créer un nouveau — il n'y a pas\nde méthode `PATCH` sur cet endpoint.\n",
        "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\n`created_at DESC, id DESC`.\n\nLes devis archivés (`archived_at IS NOT NULL`) sont exclus par\ndéfaut — l'API publique ne les expose pas. Pour récupérer un devis\narchivé, utiliser le dashboard.\n\n### Filtres\n\n- `?status` — filtre exact (`draft`, `sent`, `signed`, `cancelled`)\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "signed",
                "cancelled"
              ]
            },
            "description": "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\ndes lignes, calculer les montants, ou envoyer le devis au prospect,\nutiliser le dashboard ou l'endpoint `POST /v1/quotes/{id}/send`.\n\nLe `quote_number` doit être unique par client (contrainte DB) — un\ndoublon retourne `500 internal_error` (pas encore mappé en\n`409 duplicate_resource`).\n",
        "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).\nAu moins un champ doit être fourni sinon `400 no_fields`.\n\nLes montants (`subtotal`, `tps_amount`, `tvq_amount`, `total`) ne\nsont pas éditables via PATCH — calculés serveur-side à partir des\nblocks (gérés via le dashboard).\n\nModifier `status` directement contourne le flow standard\n`POST /v1/quotes/{id}/send` (qui horodate `sent_at` automatiquement).\nÀ utiliser avec discernement (ex passer un draft à `cancelled`).\n\nLes devis archivés (`archived_at IS NOT NULL`) ne sont pas\nmodifiables — retourne `404 resource_not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "quotes:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteUpdate"
              },
              "example": {
                "prospect_name": "Alexandre Tremblay",
                "valid_until": "2026-07-31",
                "payment_terms": "Net 60"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "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\nd'email réellement** — la délivrance email passe par le dashboard\nCapturia (Resend + templates). Cette limitation est documentée\ndans la réponse via un champ top-level `warning`.\n\n### Forme de réponse non-standard\n\nLa réponse retourne `{data, warning}` au lieu de `{data}` seul —\nle champ `warning` top-level n'est pas dans la convention v1\nstandard. Documenter dans le code intégrateur que ce champ peut\nêtre présent et indique une limitation fonctionnelle, pas une\nerreur.\n\n### États terminaux\n\n- `409 already_signed` si le devis est déjà signé (statut terminal).\n- `409 quote_cancelled` si le devis a été annulé (non renvoyable).\n\nAucun body n'est requis — l'opération est purement transitionnelle.\n",
        "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\n`warning` top-level signalant que l'API ne déclenche pas\nl'envoi email réel.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$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\n`name ASC, id ASC` — l'ordre alphabétique facilite la construction\nd'une UI de sélection. Le tiebreaker `id ASC` évite qu'un doublon de\nnom (autorisé en base) ne provoque skip ou duplication entre pages.\n\n### Sanitization du curseur\n\nLe `name` étant user-controlled, le curseur encode les valeurs avec\néchappement PostgREST côté serveur — pas d'action requise côté client.\n",
        "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\nobligatoire — `color` reçoit la valeur par défaut `#6b7280` (gris\nneutre) si non fournie. Pour attacher le tag à un contact existant,\nutiliser `POST /v1/contacts/{id}/tags` avec l'identifiant retourné\npar cet endpoint.\n\nAucune contrainte d'unicité sur `name` — deux tags peuvent partager\nle même nom (différenciés par leur `id` et `color`).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "tags:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagCreate"
              },
              "example": {
                "name": "Lead chaud",
                "color": "#ef4444",
                "description": "Prospect avec budget confirmé et calendrier court.",
                "category": "priority"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tag 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).\nTri stable `created_at DESC, id DESC`. Les automations archivées\n(`archived_at IS NOT NULL`) sont exclues — l'API publique ne les\nexpose pas.\n\n### Filtres\n\n- `?status=active` — uniquement les automations `is_active=true`.\n- `?status=inactive` — uniquement les automations `is_active=false`.\n\n### Lecture seule\n\nL'API publique expose seulement la lecture (liste + historique) et le\ndéclenchement manuel. La création/édition/archivage d'une automation\npasse par le dashboard — aucun endpoint POST/PATCH/DELETE\n`/v1/automations` n'existe.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "inactive"
              ]
            },
            "description": "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\"`,\nréclamable par le moteur d'automations (asynchrone, pas de garantie de\ncomplétion synchrone). Pour suivre la progression de l'exécution,\ninterroger `GET /v1/automations/{id}/history`.\n\n### Préconditions\n\n- L'automation doit appartenir au client appelant et ne pas être\n  archivée — sinon `404 not_found`.\n- L'automation doit avoir `is_active=true` — sinon\n  `409 automation_inactive`.\n- L'automation doit être publiée — sinon `409 automation_not_published`.\n- L'automation publiée doit posséder un noeud déclencheur — sinon\n  `409 no_trigger`.\n- Le `contact_id` doit appartenir au client appelant — sinon\n  `404 not_found`.\n\n### Gardes d'enrôlement\n\nLe déclenchement manuel passe par les mêmes gardes que les déclencheurs\nautomatiques :\n\n- Contact déjà dans un parcours actif de cette automation — sinon\n  `409 already_enrolled` (activer la multi-opportunité dans les réglages\n  d'enrôlement pour autoriser des parcours concurrents).\n- Doublon dans les 5 dernières minutes, plafond de 5 enrôlements par\n  contact par 24 h sur cette automation, ou plafond global de 200\n  exécutions par heure pour le compte — sinon `429 trigger_throttled`.\n\nLe body accepte un `event_data` optionnel (objet JSON libre) injecté dans\nl'exécution et exploitable par les variables de template du graphe.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:trigger"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationTriggerInput"
              },
              "example": {
                "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                "event_data": {
                  "source": "crm_import"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "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).\nTri stable `created_at DESC, id DESC` — les exécutions récentes en\npremier. Une exécution représente un passage de l'automation pour un\ncontact donné ; le moteur peut la redémarrer après échec\n(`retry_count > 0`).\n\nL'automation doit appartenir au client appelant et ne pas être\narchivée — sinon `404 not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "automations:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "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).\nTri stable `created_at DESC, id DESC`.\n\nUn deal n'est PAS une entité distincte en base : c'est un\n`client_contacts` ayant `pipeline_stage_id IS NOT NULL`. Le sous-ensemble\nde champs exposé est volontairement réduit aux infos pertinentes pour\nla vue pipeline (coordonnées + stage + valeur + score). Pour le contact\ncomplet (custom_fields, tags, notes), utiliser `GET /v1/contacts/{id}`.\n\n### Filtres\n\n- `?stage_id` (uuid) — filtre exact sur la stage du pipeline.\n\n### Pas de GET single\n\nAucun endpoint `GET /v1/pipeline/deals/{id}` n'est exposé — pour\nrécupérer un deal individuel, utiliser `GET /v1/contacts/{id}` ou\nfiltrer cette liste par `?stage_id`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          },
          {
            "name": "stage_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "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\n`pipeline_stage_id`). **Ne crée pas de contact** — `contact_id` doit\ndéjà exister et appartenir au client appelant (sinon `404 not_found`).\nDe même pour `stage_id` (`404 not_found` si la stage n'appartient pas\nau client).\n\nSi le contact est déjà dans une stage, son `pipeline_stage_id` est\nécrasé par la nouvelle valeur — pas d'historique de mouvement\nenregistré (la table `pipeline_history` référence l'autre table\ninterne `contacts`, pas `client_contacts`).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DealCreate"
              },
              "example": {
                "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                "stage_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Deal 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\ndéplacement de stage est exposé via cet endpoint — pour modifier les\nautres champs du deal (email, deal_value, etc.) utiliser\n`PATCH /v1/contacts/{id}`.\n\n### Préconditions\n\n- Le deal cible doit exister, appartenir au client appelant, et avoir\n  `pipeline_stage_id IS NOT NULL` — sinon `404 not_found`.\n- La stage cible doit appartenir au client appelant — sinon\n  `404 not_found`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "pipeline:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DealUpdate"
              },
              "example": {
                "stage_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deal 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\ncontact n'est PAS supprimé — il reste accessible via\n`GET /v1/contacts/{id}`. Pour supprimer définitivement le contact,\nutiliser `DELETE /v1/contacts/{id}`.\n\nLe deal cible doit exister, appartenir au client appelant, et avoir\n`pipeline_stage_id IS NOT NULL` — sinon `404 not_found`.\n",
        "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\n`stage_order ASC`. Chaque stage est enrichie d'un compteur\n`contact_count` calculé en temps réel (nombre de contacts du client\nactuellement positionnés sur cette stage — utile pour les KPI de\npipeline).\n\n### Réponse non-paginée\n\nL'endpoint retourne **toutes les stages du client en un seul appel**\n(généralement < 20 par client, max 50). Le format de réponse reste\ncompatible `list V2` pour la cohérence des intégrations\n(`pagination.next_cursor=null`, `pagination.has_more=false`,\n`pagination.limit` et `pagination.total_count` égalent le nombre de\nstages retournées).\n\n### Lecture seule\n\nCréation / édition / réordonnancement des stages passe par le\ndashboard — aucun endpoint POST/PATCH/DELETE `/v1/pipeline/stages`\nn'est exposé.\n",
        "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\nstable `last_message_at DESC, id DESC` — les conversations avec un\néchange récent en premier. `last_message_at` est `NOT NULL` côté table.\n\n### Lecture seule\n\nL'API publique expose seulement la liste des threads. Le détail des\nmessages individuels d'une conversation n'est pas encore exposé via\nl'API publique — le payload se limite aux métadonnées du thread.\nPour envoyer un email, utiliser `POST /v1/emails/send`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "email:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          },
          {
            "$ref": "#/components/parameters/CreatedAfterParam"
          },
          {
            "$ref": "#/components/parameters/CreatedBeforeParam"
          }
        ],
        "responses": {
          "200": {
            "description": "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.\nL'email est créé avec `status=\"queued\"`, `priority=1`,\n`type=\"transactional\"` et délivré de manière asynchrone par le worker\nemail — la réponse 201 confirme la mise en queue, pas la délivrance\nfinale.\n\n### Expéditeur résolu serveur-side\n\nL'expéditeur (`from`) est dérivé du sender par défaut du client\n(`client_email_senders.is_default = true`). Si aucun sender par\ndéfaut n'est configuré, la requête retourne\n`400 no_sender_configured`.\n\n### Idempotency\n\nLe header `Idempotency-Key` n'est PAS supporté sur cette route — un\nretry naïf créera un second envoi. Une clé d'idempotence interne est\ngénérée serveur-side (`api-transactional/{clientId}/{ts}`) pour\nprotéger uniquement contre les doubles inserts en base.\n\n### Mode test\n\n`\"test\": true` dans le body permet de valider une intégration sans\nviser un vrai contact : les variables de personnalisation sont\ninterpolées avec des valeurs d'exemple, le sujet est préfixé\n`[TEST]`, et les vérifications de conformité destinataire sont\ncontournées. En contrepartie, le destinataire doit être un membre\nactif de l'équipe du client (sinon 400 `recipient_not_allowed`) et\nun quota de 50 tests / 24 h glissantes s'applique, partagé avec les\nboutons de test de la plateforme (sinon 429 `test_quota_exceeded`).\n",
        "security": [
          {
            "ApiKeyAuth": [
              "email:send"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmailSendPayload"
              },
              "example": {
                "to": "alex@example.com",
                "subject": "Confirmation de votre commande",
                "body": "<p>Bonjour Alex,</p><p>Votre commande est confirmée.</p>"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Email 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\nappelante. Les souscriptions créées par une autre clé du même client\nne sont PAS visibles ici (filtre `client_id AND api_key_id`).\n\n### Pas de pagination cursor\n\nLe maximum de 10 souscriptions par client tient toujours dans une\nseule page. La réponse retourne `pagination.next_cursor=null` et\n`has_more=false`.\n\n### Secret jamais retourné\n\nLe secret HMAC utilisé pour signer les payloads sortants n'est\nretourné qu'à la création (`POST /v1/webhooks`). Il n'est pas\nrelu ici — pour le faire tourner, supprimer la souscription et\nen créer une nouvelle.\n",
        "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\nsigné HMAC à `url` chaque fois qu'un événement souscrit survient.\n\n### URL HTTPS obligatoire\n\nL'URL doit utiliser le schéma `https://` — `http://` est rejeté pour\néviter la fuite des payloads en clair.\n\n### Limite de 10 souscriptions par client\n\nAu-delà de 10 souscriptions actives sur un même client (toutes clés\nconfondues), retourne `429 subscription_limit_reached`. Supprimer\nune souscription existante avant d'en créer une nouvelle.\n\n### Secret HMAC retourné UNE seule fois\n\nLa réponse 201 inclut `secret` en clair (64 caractères hex, 256 bits\nd'entropie). Le client DOIT le stocker immédiatement — il sert à\nvérifier la signature des payloads sortants\n(`X-Capturia-Signature`) et n'est plus jamais retourné par l'API.\nLe secret est chiffré en base après cette réponse.\n\n### Scope de la clé créatrice\n\nLa souscription appartient à la fois au client ET à la clé API qui\nl'a créée. Les endpoints `GET /v1/webhooks` et\n`DELETE /v1/webhooks/{id}` ne voient que les souscriptions de la\nclé courante — une souscription créée par une autre clé du même\nclient n'est ni listée ni supprimable via l'API.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreate"
              },
              "example": {
                "url": "https://hooks.example.com/capturia",
                "events": [
                  "lead_created",
                  "payment_received"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Souscription créée. Le `secret` HMAC est inclus dans la réponse\nen clair UNE seule fois — stocker immédiatement.\n",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestID"
              },
              "Capturia-Request-ID": {
                "$ref": "#/components/headers/CapturiaRequestID"
              },
              "Capturia-Version": {
                "$ref": "#/components/headers/CapturiaVersion"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/WebhookCreated"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                    "url": "https://hooks.example.com/capturia",
                    "events": [
                      "lead_created",
                      "payment_received"
                    ],
                    "is_active": true,
                    "created_at": "2026-05-04T12:00:00.000Z",
                    "secret": "8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/InvalidApiKey"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "415": {
            "$ref": "#/components/responses/InvalidContentType"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitExceeded"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ResourceIdParam"
        }
      ],
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Supprimer une souscription webhook",
        "description": "Supprime définitivement une souscription webhook. La souscription\ndoit appartenir à la fois au client appelant ET à la clé API\nappelante (filtre `client_id AND api_key_id`) — sinon\n`404 not_found`.\n\n### Pas de soft delete\n\nLa suppression est immédiate et irréversible — pour réactiver le\nflux d'événements, créer une nouvelle souscription via\n`POST /v1/webhooks` (un nouveau secret sera émis).\n",
        "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\nla forme du payload livré par une souscription webhook** — le même\nconstructeur de payload sert la livraison réelle et cette liste.\n\n### À quoi ça sert\n\n- **`performList` des apps Zapier/Make** : quand un utilisateur teste\n  un trigger, l'app affiche de vrais échantillons récents, garantis\n  identiques à ce qu'une livraison réelle enverra.\n- **Debug d'intégration** : inspecter ce qui serait livré sans\n  attendre un nouvel événement.\n\nLa liste fonctionne même si aucune souscription webhook n'existe :\nelle est bâtie depuis le journal d'activité du compte, filtré au\ncatalogue des événements livrables. Les types d'événements internes\n(audit) n'apparaissent jamais, et les clés sensibles du champ\n`changes` sont expurgées comme à la livraison.\n\n### Différence avec une livraison réelle\n\nLe champ `id` de chaque item est l'identifiant de l'événement dans\nle journal d'activité — lors d'une livraison réelle, `id` est\nl'identifiant unique de la livraison (`X-Capturia-Delivery`). La\nforme et tous les autres champs sont identiques.\n\n### Pas de pagination cursor\n\nC'est un flux d'échantillons récents, plafonné à 25 items\n(`limit`, défaut 10). La réponse retourne\n`pagination.next_cursor=null` et `has_more=false`.\n",
        "security": [
          {
            "ApiKeyAuth": [
              "webhooks:manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Restreint aux événements de ce type (une valeur du catalogue,\nex. `lead_created`, `booking_created`, `automation_completed`).\nUne valeur hors catalogue répond `400 validation_error`\n(`reason: unknown_event_type`).\n",
            "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\ns'écarte des conventions standard de l'API v1 sur plusieurs points\ndocumentés ci-dessous. Il est appelé par la gateway pré-suasion\n(`demo.capturia.io`) quand un visiteur complète un formulaire\nd'outil pré-suasion publié sur le custom domain d'un client\nCapturia.\n\n### Authentification : défense en profondeur\n\nPas de clé API Bearer (l'endpoint est appelé par une gateway publique,\npas par un client tiers). Trois couches superposées protègent\nl'endpoint :\n\n1. **Rate-limit IP** (`RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT` req par\n   `LEAD_WINDOW_SECONDS`) — coupe le bruit haute fréquence.\n2. **Signature HMAC** `X-Capturia-Signature: t=<unix>,v1=<hex>` —\n   pattern Stripe webhook, SHA-256 sur `${timestamp}.${rawBody}`,\n   fenêtre replay 5 min, rotation 2-clés (`PRESUASION_HMAC_SECRET` +\n   `PRESUASION_HMAC_SECRET_PREVIOUS`). Mode log-only par défaut, flip\n   fail-closed via `PRESUASION_HMAC_REQUIRED=true`.\n3. **Token Cloudflare Turnstile** (champ `turnstile_token` du body)\n   vérifié siteverify avec `idempotency_key` = `submission_id` du\n   body (stable entre les retries d'une même soumission — un token\n   est à usage unique, Cloudflare rejoue le verdict original sur la\n   même clé), avec repli sur le `request_id` de la requête quand la\n   soumission ne porte pas de `submission_id`. Mode log-only par\n   défaut, flip fail-closed via\n   `PRESUASION_TURNSTILE_REQUIRED=true`.\n\nEn complément, un **quota par `proposition_slug`** (100 submissions\npar jour UTC, Upstash KV) borne le blast radius d'une attaque ciblée\nmême si les autres couches sont contournées.\n\nLe client cible est résolu serveur-side via `proposition_slug` →\n`presuasion_instances.client_id` — le `client_id` n'est jamais\naccepté depuis le body.\n\n### Body et réponse au format custom\n\nLe body suit un schéma propre à cet endpoint\n(`PresuasionSubmissionPayload`). La réponse 200 retourne un objet\nplat `{success, contact_id, contact_created, activity_id}` au lieu\nde l'enveloppe v1 standard `{data: ...}`. Les erreurs retournent\n`{error: \"string\", issues?: [...]}` au lieu d'`ErrorEnvelope`.\n\n### Comportement\n\n- Resolve le client cible via `proposition_slug` actif.\n- Upsert le contact par `(lower(email), client_id)` — enrichit les\n  champs absents (`first_name`, `phone`) sans jamais écraser les\n  valeurs existantes non-null.\n- Insère une activité `presuasion_submission` dans la timeline du\n  contact avec un bloc humain `Q/R` formaté + `metadata` brute\n  (responses, result_data, tool_id/name, proposition).\n\n### Rate limit\n\nÉmet les headers legacy `X-RateLimit-*` (compteur IP) — pas les\nheaders `RateLimit-*` RFC 9331 du reste de l'API v1.\n",
        "security": [],
        "parameters": [
          {
            "in": "header",
            "name": "X-Capturia-Signature",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^t=\\d+,v1=[0-9a-f]{64}$",
              "example": "t=1714834200,v1=0a2c…d3"
            },
            "description": "Signature HMAC SHA-256 calculée par le gateway sur\n`${timestamp}.${rawBody}` avec `PRESUASION_HMAC_SECRET`. Fenêtre\nde replay 5 minutes. Optionnel en mode log-only ; obligatoire\nquand `PRESUASION_HMAC_REQUIRED=true` (réponse 401 sinon).\n"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PresuasionSubmissionPayload"
              },
              "example": {
                "proposition_slug": "blueprint-ventes-2026",
                "tool_id": "calculateur-roi",
                "tool_name": "Calculateur de ROI",
                "email": "alex@example.com",
                "first_name": "Alex",
                "phone": "+15145551234",
                "responses": {
                  "budget": "500-1000",
                  "timeline": "3-mois"
                },
                "formatted_answers": [
                  {
                    "question": "Quel est ton défi principal en ventes ?",
                    "answer": "Je perds des leads parce que je ne suis pas assez rapide à les rappeler."
                  }
                ],
                "result_data": {
                  "score": 87,
                  "recommendation": "premium"
                },
                "completed_at": "2026-05-04T12:34:56.789Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Soumission enregistrée. Contact upserté + activité\n`presuasion_submission` créée dans la timeline.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionResponse"
                },
                "example": {
                  "success": true,
                  "contact_id": "8f3b1a2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3",
                  "contact_created": false,
                  "activity_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                }
              }
            }
          },
          "400": {
            "description": "Body JSON invalide ou payload Zod invalide. **Convention\nlegacy** — body custom `{error, issues?}` non aligné avec\n`ErrorEnvelope` du catalog v1.\n",
            "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,\nexpirée (fenêtre 5 min) ou invalide. Émis uniquement quand\n`PRESUASION_HMAC_REQUIRED=true` (mode fail-closed). En mode\nlog-only, les requêtes non signées passent et un warning est\némis pour observabilité.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Invalid signature"
                }
              }
            }
          },
          "403": {
            "description": "Token Cloudflare Turnstile manquant, expiré ou rejeté par\nsiteverify. Émis uniquement quand\n`PRESUASION_TURNSTILE_REQUIRED=true` (mode fail-closed).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Captcha verification failed"
                }
              }
            }
          },
          "404": {
            "description": "`proposition_slug` inconnu ou désactivé\n(`presuasion_instances.is_active = false`).\n",
            "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\nCapturia (`client_id IS NULL`). Erreur de configuration côté\nadmin — la soumission est rejetée plutôt que silencieusement\ndroppée.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PresuasionSubmissionError"
                },
                "example": {
                  "error": "Proposition not linked to a Capturia client"
                }
              }
            }
          },
          "429": {
            "description": "Deux variantes possibles :\n\n* **Rate limit IP dépassé** — body `{error: \"Too many requests\"}`,\n  headers `X-RateLimit-*` (convention legacy).\n* **Quota par `proposition_slug` dépassé** (100/jour UTC) — body\n  `{error: \"Slug quota exceeded\"}` accompagné du header standard\n  `Retry-After` (secondes jusqu'à minuit UTC) en plus des\n  `X-RateLimit-*`.\n",
            "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\nactivité). **Convention legacy** — body custom\n`{error: \"string\"}` au lieu d'`ErrorEnvelope`.\n",
            "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\n`Authorization: Bearer cap_live_...`. Les clés legacy au format `cap_<hex64>`\nsont également acceptées jusqu'au 2027-05-03.\n\n## Scopes disponibles\n\nChaque clé porte une liste de scopes qui déterminent quelles opérations elle\npeut effectuer. Les scopes sont vérifiés à chaque requête côté serveur.\n19 scopes au total : 8 scopes de lecture, 9 scopes d'écriture/déclenchement,\net 2 scopes d'ingestion.\n\n### Scopes de lecture\n\n- `contacts:read` — Lire les contacts\n- `pipeline:read` — Lire les deals et stages du pipeline\n- `conversations:read` — Lire les threads de conversation (chat, SMS, appel, email)\n- `bookings:read` — Lire les rendez-vous\n- `quotes:read` — Lire les devis\n- `tags:read` — Lire les tags\n- `automations:read` — Lire les automations\n- `email:read` — Lire les threads email\n\n### Scopes d'écriture\n\n- `contacts:write` — Créer/modifier/supprimer des contacts\n- `pipeline:write` — Créer/modifier des deals\n- `bookings:write` — Créer/modifier des rendez-vous\n- `quotes:write` — Créer/modifier des devis\n- `tags:write` — Créer/modifier des tags\n- `automations:trigger` — Déclencher manuellement une automation\n- `email:send` — Envoyer un email transactionnel\n- `webhooks:manage` — Créer/modifier des webhooks sortants\n- `campaigns:launch` — Prévisualiser puis lancer une campagne de relance SMS (connecteur MCP, confirmation en 2 temps obligatoire)\n\n### Scopes d'ingestion\n\n- `leads:capture` — Pousser des leads depuis un funnel externe (le plus utilisé pour intégrations partenaires)\n- `events:ingest` — Pousser des événements personnalisés qui déclenchent les automatisations (`POST /v1/events`)\n\n## Presets\n\nPour simplifier la création, 5 presets de scopes sont proposés à la création :\n`Capture de leads` (`leads:capture` seul), `Lecture seule` (tous les `:read`),\n`Lecture + écriture` (tous `:read` + `:write` + `leads:capture` + `events:ingest`),\n`Intégration complète` (tous les scopes), `Personnalisé` (sélection libre).\n\nDocumentation complète : https://capturia.io/fr/developers/api/v1/authentication\n"
      }
    },
    "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\nroutes sont accessibles : `:read` pour lecture, `:write` pour mutation,\net quelques scopes spécifiques (`leads:capture`, `events:ingest`,\n`automations:trigger`, `email:send`, `webhooks:manage`,\n`campaigns:launch`). La liste effective d'une clé est figée à sa\ncréation — pour changer les scopes, créer une nouvelle clé.\n",
        "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\nDB — recalculé au runtime).\n\n- `lead_capture` — scopes = `[leads:capture]` exactement\n- `read_only` — tous les scopes `:read`\n- `read_write` — tous les `:read` + tous les `:write` + `leads:capture` + `events:ingest`\n- `full_integration` — tous les scopes disponibles\n- `custom` — toute combinaison qui ne matche aucun preset connu\n",
        "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`.\nUtilisée comme outil de smoke test — un dev qui intègre Capturia confirme\nrapidement que sa clé fonctionne et lit les infos qu'elle expose\n(client cible, scopes, preset, expiration, rate limit applicable).\n\nLe secret de la clé n'est jamais retourné — seul `key_prefix` (8-12\npremiers caractères type `cap_live_…`) sert d'identifiant lisible côté\ndashboard.\n",
        "required": [
          "key_id",
          "key_name",
          "key_prefix",
          "key_format_version",
          "client_id",
          "scopes",
          "preset",
          "lifecycle_state",
          "created_at",
          "rate_limits"
        ],
        "properties": {
          "key_id": {
            "type": "string",
            "format": "uuid",
            "description": "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é\ndashboard pour identifier visuellement la clé sans exposer le secret.\n",
            "example": "cap_live_a1b2c3"
          },
          "key_format_version": {
            "type": "integer",
            "description": "Version du format de la clé (1, 2, …). Permet de gérer les rotations\nde format sans casser les clés existantes. Les nouvelles clés\nutilisent toujours la dernière version.\n",
            "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\nrenseigné (cas rare, comptes système).\n",
            "example": "Acme Inc."
          },
          "scopes": {
            "type": "array",
            "description": "Scopes effectifs portés par la clé (filtrés contre la liste des\nscopes valides — d'éventuels scopes inconnus en DB sont écartés).\n",
            "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`,\n`paused`, `revoked`. Une clé `paused` ou `revoked` ne passe pas\nl'authentification — si vous voyez `active` ici, c'est que la clé\nfonctionne.\n",
            "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`\nsi jamais utilisée.\n",
            "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\nautomatiquement. Une clé expirée renvoie 401 `expired_api_key`.\n",
            "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`).\nUne bucket représente une heure ou un jour selon la `granularity` demandée\nsur `GET /v1/usage`. Les latences sont calculées en millisecondes\n(percentiles serveur-side, `null` si aucune requête mesurée sur la fenêtre).\n",
        "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\ngranularité `hour` : aligné sur l'heure pleine. Pour `day` : aligné\nsur 00:00:00 UTC.\n",
            "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\n429 inclus, etc.).\n",
            "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\n`status_4xx` — utile pour dimensionner sa consommation.\n",
            "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\n(pas de latence agrégée — les percentiles ne sont pas additifs).\n",
        "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\n(`api_key_id` = clé courante). La plage temporelle (`from` → `to`) doit\nêtre strictement positive et ne pas dépasser 90 jours\n(sinon 400 `invalid_request`).\n\nLes `buckets` sont triés par `bucket_start ASC`. Une fenêtre sans trafic\nest absente du tableau (les buckets ne sont pas zéro-paddées).\n",
        "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\nmarketing dans le pipeline (`Source : Google Ads / Facebook campaign X`).\n",
        "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\nfunnel, quiz pré-suasion). Stockée pour enrichir le contexte du bot SMS\net alimenter le scoring de lead.\n",
        "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\noptionnels — fournir au moins `pipeline_slug` pour cibler un pipeline.\nSi `stage_slug` est omis, le stage par défaut du pipeline est utilisé.\n`assigned_sales_rep_email` doit correspondre à un membre de l'équipe\nclient (sinon `assignment_status` retourné = `email_not_found`).\n",
        "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\n(`is_default=true`) du pipeline est utilisé.\n",
            "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 —\nun timestamp complet est rejeté.\n",
            "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\nclient appelant (`client_users.status = 'active'`) ayant déjà activé\nson compte (invitation acceptée). Sinon la capture continue sans\nassignation et `assignment_status = email_not_found` dans la réponse\n(pas une erreur).\n",
            "example": "alex@acme.com"
          }
        }
      },
      "LeadCaptureMetaAttribution": {
        "type": "object",
        "description": "Attribution publicitaire Meta du visiteur, capturée à la soumission côté\nfunnel et persistée (first-touch) sur le contact. Sert à attribuer\nl'événement Purchase server-side (Conversions API) au clic d'origine quand\nle deal passe « gagné ». Tous les champs sont optionnels — fournir ce qui\nest disponible améliore la qualité du matching Meta. Un lead qui revient ne\nréécrit pas son attribution d'origine (first-touch).\n\nLes identifiants `ad_id`/`adset_id`/`campaign_id` (et leurs libellés)\nrattachent en plus le lead à la publicité qui l'a amené — visible sur sa\nfiche et dans la performance par pub. Les connecteurs branchés sur Meta\nLead Ads (Zapier, Make, n8n) disposent de ces champs dans le payload du\nlead.\n",
        "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\nde l'appelant serveur). Acceptée pour la qualité du matching Meta.\n"
          },
          "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\nconsentement a été recueilli en amont (votre propre bandeau, ou un lead\nMeta Lead Ads) et les événements de conversion partent vers Meta.\n`false` = le lead a refusé le suivi publicitaire : aucun événement Meta\nn'est envoyé, aucun identifiant de suivi (`fbc`/`fbp`) n'est retenu, et\nle refus est persisté sur le contact — il bloque aussi ses événements\nfuturs (vente gagnée, lead qualifié). Booléen, ou équivalent des\nconnecteurs no-code : `1`/`0` numérique, ou chaîne\n(`\"true\"`/`\"yes\"`/`\"oui\"`/`\"1\"` et `\"false\"`/`\"no\"`/`\"non\"`/`\"0\"`,\ninsensible à la casse). `null` vaut « champ omis ».\n",
            "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\n`sms_consent` sont obligatoires — Capturia est centré SMS et la\ncaptation suppose un opt-in explicite.\n\n`phone` est normalisé serveur-side avant validation : un numéro à 10\nchiffres sans indicatif est assumé NANP (`+1XXXXXXXXXX`), un numéro à\n11 chiffres commençant par `1` reçoit le `+`, et les formats avec\nparenthèses/tirets (`(514) 123-4567`, `1-514-123-4567`) sont acceptés.\nPour un numéro international, fournir explicitement le `+` et\nl'indicatif pays (ex `+33612345678`). Un téléphone livré en nombre JSON\n(champ numérique d'un connecteur no-code) est accepté s'il est un entier\npositif — voir la propriété `phone`.\n\n`sms_consent` accepte le boolean `true` ou les chaînes truthy\n`\"true\" | \"yes\" | \"oui\" | \"vrai\" | \"1\" | \"on\" | \"checked\"`\n(insensible à la casse). Toute autre valeur est rejetée — Zapier, Make\net la plupart des form-builders stringifient les booleans, alors\naccepter ces variantes courantes évite de perdre des consentements\nvalides.\n\nBody cap : 1 MB. Au-delà : 400 `payload_too_large`.\n\nTolérance no-code : les champs d'identité sont normalisés serveur-side\n(casse et accents ignorés, alias courants comme `firstName`, `Full Name`,\n`phone_number`, `Courriel` rapprochés des clés canoniques). Les clés non\nreconnues sont ignorées plutôt que rejetées, d'où `additionalProperties`.\nUn champ optionnel envoyé vide (`\"\"` ou `null`) est traité comme absent\nplutôt que rejeté. `tags` accepte aussi une chaîne à virgules\n(`\"chaud, webinaire\"`), coercée en tableau.\n\nWebhooks GoHighLevel : l'action Webhook des workflows GHL enveloppe les\npaires « Custom Data » dans un objet imbriqué `customData`. Capturia\ndéplie ce wrapper d'un niveau — chaque paire est traitée comme si elle\nétait à la racine (mêmes alias, même validation), la racine gardant\npréséance en cas de doublon. Aucune configuration requise côté GHL.\n",
        "required": [
          "phone",
          "sms_consent"
        ],
        "additionalProperties": true,
        "properties": {
          "phone": {
            "description": "Téléphone (sera normalisé E.164). Format final attendu :\n`^\\+[1-9]\\d{10,14}$` (entre 11 et 15 chiffres après le `+`).\nAussi accepté : un entier JSON positif dans la zone sûre JavaScript\n(au plus 9007199254740991 — tout numéro E.164 y tient), converti en\nchaîne avant normalisation ; connecteurs no-code dont le champ source\nest numérique. Préférer la chaîne — seule forme qui préserve un `+`\ninternational explicite. Tout autre nombre (négatif, décimal, zéro)\nest refusé en 422 `invalid_phone` avec le rappel des formats acceptés.\n",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 9007199254740991
              }
            ],
            "example": "+15145551234"
          },
          "sms_consent": {
            "description": "Consentement SMS explicite. Doit être `true` (boolean) ou une\nchaîne truthy whitelisted (voir description du schema). Les\nvaleurs ambiguës (`false`, `\"no\"`, `0`, etc.) sont rejetées avec\n422 `missing_consent`. Accepté à la racine du body ou dans\nl'objet `customData` (webhooks GoHighLevel).\n",
            "oneOf": [
              {
                "type": "boolean",
                "enum": [
                  true
                ]
              },
              {
                "type": "string"
              }
            ],
            "example": true
          },
          "first_name": {
            "type": "string",
            "maxLength": 100,
            "example": "Alex"
          },
          "last_name": {
            "type": "string",
            "maxLength": 100,
            "example": "Tremblay"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "example": "alex@example.com"
          },
          "source_label": {
            "type": "string",
            "maxLength": 200,
            "description": "Libellé libre identifiant la source du lead (ex `funnel-fb-jan`,\n`landing-blueprint`). Affiché dans le pipeline et le journal\nd'activité du contact.\n",
            "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\nclient, il est créé à la volée.\n",
            "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\nconfigurée côté client (ex `type_propriete`), pas par son UUID interne —\nl'integrator d'un site ne connaît pas les ids. La capture résout\nname → définition par client puis remplit la fiche contact et le kanban.\nUne clé sans définition correspondante est ignorée silencieusement (pas\nd'erreur). Pour un champ `select`, fournir l'option `value`\n(ex `maison_isolee`), pas le label.\n",
            "propertyNames": {
              "maxLength": 100
            },
            "additionalProperties": {
              "type": [
                "string",
                "number",
                "boolean"
              ],
              "maxLength": 2000
            },
            "example": {
              "type_propriete": "maison_isolee",
              "delai_vente": "0_3_mois"
            }
          },
          "address": {
            "type": "string",
            "maxLength": 500,
            "description": "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\nautocomplete. Stocké tel quel pour ré-résolution ultérieure.\n",
            "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\nle dashboard Capturia). Le lead capturé est engagé dans ce Système.\nAbsent → Système principal (comportement historique). Un UUID qui ne\ncorrespond à aucun Système du compte → erreur `system_not_found` (422),\naucun contact créé. Un Système du compte mais archivé → repli silencieux\nsur le Système principal.\n",
            "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
          }
        }
      },
      "LeadCapturePipelineEcho": {
        "type": "object",
        "description": "Écho du routage pipeline **résolu** : le pipeline demandé (slug → pipeline\net stage du compte) quand `pipeline.pipeline_slug` a été fourni, sinon le\npipeline par défaut du compte — celui où un lead capturé sans routage est\nplacé. `null` seulement quand aucun slug n'est fourni ET que le compte n'a\npas de pipeline par défaut. Sur une fiche créée par l'appel\n(`contact_was_new: true`), ce routage est celui appliqué à la fiche. Sur\nune fiche existante retrouvée (`contact_was_new: false`), la fiche\nconserve son pipeline et son étape déjà en place (les champs cœur ne sont\njamais écrasés) : l'écho reflète alors la demande résolue, pas forcément\nl'état persisté de la fiche.\n",
        "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\n**soumission** : enregistrée (`created`), replay idempotent\n(`idempotent_replay` — submission identique reçue dans la fenêtre de\n5 minutes ou via `Idempotency-Key` connu), ou contact déjà pris en charge\n(`existing_pipeline_contact` — déjà dans une étape de pipeline ouverte).\n`contact_was_new` distingue une fiche créée par l'appel d'une fiche\nexistante retrouvée par courriel ou téléphone. Dans tous les cas, le\ncontact ID retourné est le même.\n\nLe bot SMS prend le relais dans les 30 secondes après une soumission\nenregistrée si le consentement et la configuration sont en ordre. Il n'est\njamais déclenché pour un `existing_pipeline_contact`.\n",
        "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\n  l'appel **et** une fiche existante retrouvée (même courriel ou même\n  téléphone) puis enrichie — `contact_was_new` fait la distinction.\n- `idempotent_replay` — submission déjà traitée (5-min window ou\n  Idempotency-Key match), aucun nouveau lead créé\n- `existing_pipeline_contact` — le contact est déjà dans une étape de\n  pipeline ouverte (ni gagnée ni perdue), donc déjà pris en charge :\n  aucune nouvelle soumission n'est enregistrée et le bot SMS n'est pas\n  déclenché. Les champs nouvellement fournis enrichissent quand même le\n  contact existant. Permet à une intégration (Zapier, Make, FB Lead Ads)\n  de re-pousser un lead sans perturber une conversation en cours.\n",
            "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.\n`false` quand la capture a retrouvé une fiche existante (même courriel\nou même téléphone, coordonnées secondaires incluses) : la fiche est\nenrichie sans écraser ses champs cœur — son pipeline, son étape et son\nvendeur déjà en place sont conservés, même si la capture en demandait\nd'autres.\n"
          },
          "activity_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Identifiant de l'entrée de journal d'activité créée pour cette capture.\n`null` quand `status = existing_pipeline_contact` : aucune soumission\nn'est enregistrée pour un contact déjà dans une étape de pipeline ouverte.\n"
          },
          "sms_conversation_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Toujours `null` dans la réponse actuelle : le bot SMS démarre en\nasynchrone après la capture, aucune conversation n'existe encore au\nmoment où la réponse est produite. Champ conservé pour compatibilité —\nne pas s'appuyer dessus pour détecter le démarrage du bot.\n"
          },
          "pipeline": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/LeadCapturePipelineEcho"
              },
              {
                "type": "null"
              }
            ],
            "description": "Écho du routage pipeline résolu : le pipeline demandé, sinon le\npipeline par défaut du compte. `null` quand aucun slug n'est fourni et\nque le compte n'a pas de pipeline par défaut. Sur une fiche existante\n(`contact_was_new: false`), la fiche conserve son pipeline déjà en\nplace — voir `LeadCapturePipelineEcho`.\n"
          },
          "original_created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Pour un `idempotent_replay` : horodatage de la création originale\n(utile pour distinguer un replay imminent d'un replay à plusieurs\nminutes). `null` pour un `created` frais.\n"
          },
          "assignment_status": {
            "type": "string",
            "description": "Résultat de la **résolution** du vendeur si\n`pipeline.assigned_sales_rep_email` a été fourni :\n- `not_requested` — aucun email d'assignation fourni\n- `assigned` — le vendeur demandé existe et est actif. Il est assigné\n  à une fiche créée par l'appel ; une fiche existante déjà assignée à\n  quelqu'un d'autre conserve son vendeur en place (champ cœur non\n  écrasé — voir `contact_was_new`).\n- `email_not_found` — email fourni mais aucun membre actif au compte\n  activé ne correspond. La capture continue sans assignation : une\n  fiche créée par l'appel naît sans vendeur, une fiche existante garde\n  son assignation telle quelle.\n",
            "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\nrequis parmi `external_id`, `email`, `phone`. La résolution serveur suit\nl'ordre `external_id`+`external_source` → `phone` → `email` ; les champs\nd'identité fournis enrichissent la fiche de façon non-destructive (une\nvaleur déjà présente n'est jamais écrasée).\n",
        "properties": {
          "external_id": {
            "type": "string",
            "maxLength": 255,
            "description": "Identifiant du contact dans le système source (ex. ID HubSpot, ID\nutilisateur du backend client). Idempotence source : deux événements\nportant le même `external_id` + `external_source` résolvent toujours\nla même fiche.\n"
          },
          "external_source": {
            "type": "string",
            "maxLength": 100,
            "description": "Espace de noms de l'`external_id` (ex. `hubspot`, `shopify`,\n`mon-backend`). Défaut : `api`. Ignoré sans `external_id`.\n"
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          },
          "phone": {
            "type": "string",
            "description": "Numéro accepté aux formats E.164 (`+15145551234`), NANP 10 chiffres,\n11 chiffres avec indicatif, ou variantes formatées — normalisé\nserveur-side vers E.164.\n"
          },
          "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\nidentifiants fournis, la requête répond `404 contact_not_found` et\nrien n'est enregistré.\n"
          }
        }
      },
      "EventIngestPayload": {
        "type": "object",
        "additionalProperties": false,
        "description": "Contrat strict : une clé inconnue au top-level (ou dans `contact`) est\nrejetée en `422 validation_error` — protège contre les typos\nd'intégration silencieuses. Les données libres vont sous `payload`.\n",
        "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\n(sensible à la casse) au nom configuré sur le déclencheur « Événement\npersonnalisé » des automatisations.\n",
            "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\nexposée aux messages des automatisations via `{{event.<clé>}}` (un\nobjet imbriqué est sérialisé en JSON). Le contenu persisté est borné\n(nombre de clés, profondeur, taille totale).\n"
          },
          "custom_fields": {
            "type": "object",
            "additionalProperties": true,
            "description": "Champs personnalisés du contact, keyés par le `name` de la définition\n(même contrat que `/v1/leads/capture`). Une clé sans définition\ncorrespondante est ignorée silencieusement. Merge additif : la\nnouvelle valeur gagne sur collision.\n"
          },
          "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 :\nl'événement est enregistré sur la fiche (traçabilité) mais la fiche\nn'est pas modifiée et aucune automatisation n'est enrôlée.\n"
          },
          "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\nautomatisation active n'écoute ce nom d'événement (l'événement est\nquand même enregistré sur la fiche).\n"
          },
          "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\nnullable peuvent être absents en base. `custom_fields` est un objet libre validé\ncontre les définitions de champs personnalisés du client.\n",
        "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\n`client_custom_field_definitions`. Lecture additive (pas de schéma fixe).\n"
          },
          "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.\nFormat : base64url encodé d'un objet `{v:1, f:string, s:asc|desc, val:string, id:string}`.\nLe client ne doit pas parser cette valeur — la traiter comme opaque.\n",
            "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).\nUtiliser `next_cursor` à la place.\n"
          },
          "total_count": {
            "type": "integer",
            "deprecated": true,
            "description": "Champ legacy. Estimation du total — peut diverger du count réel sur grandes tables (PostgreSQL `estimated`).\nSunset 2027-05-03. Compte exact non garanti et coûteux en perf.\n",
            "minimum": 0
          }
        }
      },
      "ContactCreate": {
        "type": "object",
        "description": "Body de création d'un contact (`POST /v1/contacts`). Email obligatoire et\ndédupliqué côté serveur — un POST avec un email déjà connu retourne 409\n`duplicate_contact` plutôt qu'un nouvel insert.\n",
        "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\ndéfinition). Validés contre `client_custom_field_definitions` à\nl'écriture — un champ inconnu ou un type incompatible retourne\n400 `invalid_custom_fields`. En lecture, les clés retournées sont\nles ids de définition (forme de stockage).\n"
          },
          "system_id": {
            "type": "string",
            "format": "uuid",
            "description": "Système cible du contact créé (UUID d'un Système du compte). Le contact\nest engagé dans ce Système. Absent → Système principal (comportement\nhistorique). UUID inconnu pour ce compte → erreur `system_not_found`\n(422), aucun contact créé ; Système archivé → repli silencieux sur le\nSystème principal. Sur `POST /v1/contacts/batch`, ce champ est ignoré\nau niveau item — utiliser le `system_id` top-level du batch, qui\ns'applique à tout le lot.\n",
            "example": "3f6d9a2e-1c4b-4e8a-9f21-7b5c0d8e4a11"
          }
        }
      },
      "ContactWithTags": {
        "description": "Contact enrichi avec ses tags (réponse de `GET /v1/contacts/{id}?expand=tags`).\n`client_contact_tags` est la table de jointure et contient le tag complet imbriqué.\n",
        "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\nsont optionnels mais au moins un doit être fourni. `custom_fields` est mergé\navec les valeurs existantes (pas remplacé).\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "example": "alex@example.com"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Tremblay"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\+[1-9]\\d{1,14}$",
            "description": "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\nfournies sont conservées). Passer `null` efface tous les champs.\n"
          }
        }
      },
      "Conversation": {
        "type": "object",
        "description": "Thread de discussion (chat web ou API tierce) appartenant à un client Capturia.\nBacked par la table `chat_sessions` — un thread agrège les messages d'un visiteur\navec un agent IA ou humain. Les coordonnées (email, phone, first_name, last_name)\nsont snapshotées sur la session ; `contact_id` pointe vers le contact unifié si\nune correspondance a été établie.\n\nCréation et envoi de messages se font côté front-end / SDK chat — l'API publique\nexpose la lecture seulement.\n",
        "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`).\nTri natural reading order (created_at ASC). Le rôle indique l'émetteur.\n",
        "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 :\n- `user` — visiteur / lead\n- `assistant` — agent IA\n- `system` — message système (init, contexte, instructions)\n",
            "enum": [
              "user",
              "assistant",
              "system"
            ],
            "example": "user"
          },
          "content": {
            "type": "string",
            "description": "Contenu textuel du message, prêt à afficher (Markdown léger possible côté\nassistant). Les marqueurs de pilotage internes de l'agent sont retirés :\nun message que l'agent a envoyé en plusieurs bulles est rendu en\nparagraphes séparés. Peut donc être une chaîne vide si le message ne\nportait que du pilotage ; la ligne reste présente pour ne pas trouer la\npagination.\n",
            "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\n`capturia_bookings`. Chaque booking est rattaché à un `project_id` qui appartient\nobligatoirement au client appelant — l'isolation tenant est appliquée via une\nrésolution préalable des projets autorisés.\n\nL'API publique permet de lister, créer (`status=\"confirmed\"` forcé) et annuler\n(soft cancel : `status=\"cancelled\"` + `cancelled_at`). Aucun PATCH disponible —\npour modifier un booking il faut l'annuler et en créer un nouveau.\n",
        "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 :\n- `confirmed` — réservé et actif (valeur par défaut à la création API)\n- `cancelled` — annulé (DELETE force cette valeur + `cancelled_at`)\n- autres valeurs possibles selon les flows internes (UI, calendriers liés)\n",
            "example": "confirmed"
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Origine de la réservation. Forcé à `api` pour les bookings créés via cet\nendpoint. Autres valeurs possibles : `widget`, `manual`, `calendar_sync`, etc.\n",
            "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\nappartenir au client appelant — sinon 404 `not_found`. Les champs `status` et\n`source` ne sont pas acceptés du caller : forcés à `confirmed` / `api` côté serveur.\n\nAucune validation du format ISO 8601 sur `start_datetime`/`end_datetime` —\ntransmis tels quels à Postgres qui rejettera un format invalide.\n",
        "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\nactif du catalogue Capturia — un identifiant inconnu ou inactif est\nrefusé `404 not_found` sans création.\n"
          }
        }
      },
      "BookingCancelResult": {
        "type": "object",
        "description": "Réponse de `DELETE /v1/bookings/{id}` — soft cancel. Le booking n'est pas supprimé\nphysiquement : son statut passe à `cancelled` et `cancelled_at` est horodaté.\nUn second DELETE sur un booking déjà annulé retourne 409 `already_cancelled`.\n",
        "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 :\n`draft` → `sent` → `signed` (terminal) ou `cancelled`. Les quotes archivés\n(`archived_at IS NOT NULL`) sont exclus de toutes les listes et opérations\nPATCH/SEND — l'API publique ne les expose pas.\n\nLes montants (`subtotal`, `tps_amount`, `tvq_amount`, `total`) sont calculés\nserveur-side à partir des blocks (lignes du devis) — non éditables via PATCH.\nLa création API initialise un quote vide (`blocks=[]`) en statut `draft` ; les\nblocks et la finalisation passent par le dashboard.\n",
        "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 :\n- `draft` — créé, pas encore envoyé\n- `sent` — envoyé au prospect (`sent_at` horodaté)\n- `signed` — signé par le prospect (`signed_at` horodaté, terminal)\n- `cancelled` — annulé (terminal pour l'envoi)\n",
            "enum": [
              "draft",
              "sent",
              "signed",
              "cancelled"
            ],
            "example": "draft"
          },
          "prospect_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Alex Tremblay"
          },
          "prospect_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "example": "alex@example.com"
          },
          "prospect_phone": {
            "type": [
              "string",
              "null"
            ],
            "example": "+15145551234"
          },
          "prospect_company": {
            "type": [
              "string",
              "null"
            ],
            "example": "Acme Inc."
          },
          "prospect_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "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\"`\navec `blocks=[]` (aucune ligne). Pour ajouter des lignes/montants ou envoyer le\ndevis au prospect, utiliser le dashboard ou les endpoints dédiés.\n",
        "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\nrequis sinon 400 `no_fields`. Les montants (`subtotal`, `total`, etc.) ne sont\npas éditables — calculés serveur à partir des blocks (gérés via dashboard).\n\nModifier `status` directement contourne le flow standard `/send` — à utiliser\navec discernement (ex passer un draft à `cancelled`).\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "prospect_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "prospect_phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_company": {
            "type": [
              "string",
              "null"
            ]
          },
          "prospect_address": {
            "type": [
              "string",
              "null"
            ]
          },
          "valid_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "payment_terms": {
            "type": [
              "string",
              "null"
            ]
          },
          "internal_notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "sales_rep_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "tax_profile_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "viewed",
              "cancelled",
              "declined"
            ],
            "description": "Cycle de vie du devis. Modifier directement `status` court-circuite le flow\nstandard `POST /v1/quotes/{id}/send` (qui horodate `sent_at` automatiquement).\n`signed` et `expired` ne sont pas atteignables par PATCH : `signed` exige le\nflux de signature réel (vérification d'identité + certificat), `expired` est\nposé par le cron d'expiration. Valeur hors liste → 400 `validation_error`.\n"
          }
        }
      },
      "QuoteSendResult": {
        "type": "object",
        "description": "Réponse de `POST /v1/quotes/{id}/send`. L'API met à jour `status=\"sent\"` et\n`sent_at` mais n'envoie PAS d'email au prospect — la délivrance email passe\npar le dashboard. Un champ top-level `warning` documente cette limitation.\n\nÉchec si le quote est déjà signé (409 `already_signed`) ou annulé (409\n`quote_cancelled`).\n",
        "required": [
          "data",
          "warning"
        ],
        "properties": {
          "data": {
            "type": "object",
            "description": "Snapshot partiel du quote après mise à jour (sous-ensemble de champs vs\n`Quote` complet — le handler ne resélectionne pas tous les champs).\n",
            "required": [
              "id",
              "client_id",
              "quote_number",
              "status",
              "sent_at",
              "created_at",
              "updated_at"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "client_id": {
                "type": "string",
                "format": "uuid"
              },
              "quote_number": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "sent"
                ],
                "description": "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\n— seule la transition de statut est effectuée. Pour envoyer l'email au\nprospect, utiliser le dashboard.\n",
            "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.\nListé par ordre alphabétique stable (`name ASC, id ASC`). La couleur est\nlibre (hexadécimale par convention) et la catégorie permet de regrouper\nplusieurs tags (ex `priority`, `industry`, `lifecycle`).\n",
        "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\n`#6b7280` (gris neutre) si non fourni à la création.\n",
            "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.\n`color` reçoit la valeur par défaut `#6b7280` côté serveur si omis.\n",
        "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\névénement (lead capturé, stage change, RDV booké, etc.) ou par appel API\nexplicite (`POST /v1/automations/{id}/trigger`). Une automation archivée\n(`archived_at IS NOT NULL`) est exclue de la liste publique et ne peut plus\nêtre triggerée.\n\nL'API publique expose la lecture (liste + historique d'exécutions) et le\ntrigger manuel. La création/édition/archivage d'une automation passe par le\ndashboard — il n'existe pas de POST/PATCH/DELETE `/v1/automations`.\n",
        "required": [
          "id",
          "name",
          "trigger_type",
          "is_active",
          "execution_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "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`,\n`stage_changed`, `booking_created`, `tag_added`, `manual`, `webhook`.\nListe non-exhaustive — gérée côté dashboard.\n",
            "example": "lead_captured"
          },
          "action_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Type d'action principale exécutée par l'automation (ex `send_email`,\n`send_sms`, `assign_to_seller`, `create_task`). `null` pour les\nautomations multi-actions définies via graphe de noeuds.\n",
            "example": "send_email"
          },
          "is_active": {
            "type": "boolean",
            "description": "Si `false`, l'automation est désactivée — les triggers événementiels\nsont ignorés et `POST /v1/automations/{id}/trigger` retourne\n409 `automation_inactive`.\n",
            "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\n(`POST /v1/automations/{id}/trigger`). Crée une exécution `status=\"pending\"`\navec `trigger_type=\"manual_api\"`, prête à être réclamée par le moteur\nd'automations. L'automation doit être `is_active=true` (sinon\n409 `automation_inactive`), publiée (sinon 409 `automation_not_published`)\net posséder un noeud déclencheur (sinon 422 `no_trigger`) ; le `contact_id`\ndoit appartenir au client appelant (sinon 404 `not_found`).\n",
        "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\nl'exécution et exploitable par les variables de template du graphe.\nVide par défaut.\n",
            "example": {
              "source": "crm_import",
              "campaign": "spring-2026"
            }
          }
        }
      },
      "AutomationTriggerResult": {
        "type": "object",
        "description": "Réponse de `POST /v1/automations/{id}/trigger`. L'API enregistre une\nexécution `pending` (réclamable par le moteur) — le traitement asynchrone\nest pris en charge par le moteur d'automations (pas de garantie de complétion\nsynchrone). Pour suivre la progression, interroger\n`GET /v1/automations/{id}/history`.\n",
        "required": [
          "id",
          "automation_id",
          "contact_id",
          "trigger_type",
          "status",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "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\n(`client_automation_executions`, exposée via\n`GET /v1/automations/{id}/history`). Une exécution représente un passage\nde l'automation pour un contact donné. Le moteur peut redémarrer une\nexécution échouée (`retry_count > 0`).\n",
        "required": [
          "id",
          "automation_id",
          "contact_id",
          "trigger_type",
          "status",
          "retry_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "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),\n`manual_dashboard` (via le dashboard), ou nom de l'événement\ndéclencheur (`lead_captured`, `stage_changed`, etc.).\n",
            "example": "manual_api"
          },
          "status": {
            "type": "string",
            "description": "État de l'exécution. Valeurs typiques : `pending`, `running`, `paused`,\n`completed`, `failed`, `cancelled`. Liste gérée côté moteur — non-enum\nici pour ne pas contraindre l'évolution.\n",
            "example": "completed"
          },
          "current_node_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Identifiant du noeud courant dans le graphe d'exécution (pour les\nautomations multi-étapes). `null` pour les automations à action unique.\n"
          },
          "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\ndes erreurs rencontrées. Schéma libre — dépend du type d'automation.\n"
          },
          "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é\ndistincte en base : c'est un `client_contacts` ayant un\n`pipeline_stage_id` non-NULL. La même ressource peut donc être manipulée\nvia `/v1/contacts/{id}` ET `/v1/pipeline/deals/{id}` — l'endpoint deals\nse contente de filtrer/écrire le champ `pipeline_stage_id`.\n\nLe sous-ensemble de champs exposé est volontairement réduit aux infos\npertinentes pour la vue pipeline (coordonnées + stage + valeur + score).\nPour le contact complet (custom_fields, tags, notes), utiliser\n`/v1/contacts/{id}`.\n\nEndpoints disponibles : `GET /v1/pipeline/deals` (liste filtrable par\n`?stage_id`), `POST /v1/pipeline/deals` (place un contact dans une stage),\n`PATCH /v1/pipeline/deals/{id}` (déplace vers une autre stage),\n`DELETE /v1/pipeline/deals/{id}` (retire du pipeline sans effacer le\ncontact). **Pas de `GET /v1/pipeline/deals/{id}` exposé** — récupérer le\ndeal individuel via `GET /v1/contacts/{id}` ou via le filtre\n`?stage_id=...` sur la liste.\n",
        "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\n(filtre `pipeline_stage_id IS NOT NULL`). Un DELETE le passe à NULL\net le contact disparaît alors de la liste deals.\n"
          },
          "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\nexistant dans une stage du pipeline. **Ne crée pas de contact** — le\n`contact_id` doit déjà exister et appartenir au client appelant (sinon\n404 `not_found`). De même pour `stage_id` (404 `not_found` si la stage\nn'appartient pas au client).\n\nSi le contact est déjà dans une stage, son `pipeline_stage_id` est écrasé\npar la nouvelle valeur — pas d'historique de mouvement enregistré\n(la table `pipeline_history` référence l'autre table interne `contacts`,\npas `client_contacts`).\n",
        "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é :\nson `pipeline_stage_id` est mis à NULL, ce qui le retire de la liste deals\nsans toucher à l'entité contact (qui reste accessible via `/v1/contacts/{id}`).\n",
        "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\ndéplacement de stage est exposé via cet endpoint — pour modifier les\nautres champs du deal (email, deal_value, etc.) utiliser\n`PATCH /v1/contacts/{id}`.\n\n`stage_id` est obligatoire (400 `validation_error` sinon). La stage cible\ndoit appartenir au client (404 `not_found` sinon). Le deal cible doit déjà\nêtre dans le pipeline (404 `not_found` si `pipeline_stage_id IS NULL`).\n",
        "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\nstage définit une étape du parcours commercial (ex `Nouveau lead`,\n`Qualifié`, `Proposition envoyée`, `Gagné`, `Perdu`). Triées par\n`stage_order ASC`.\n\nL'API publique expose la lecture seulement\n(`GET /v1/pipeline/stages`) — création/édition/réordonnancement des stages\npasse par le dashboard. La réponse est non-paginée (toutes les stages d'un\nclient tiennent généralement sous 20 entrées) mais retourne un format\ncompatible `list V2` avec `pagination.next_cursor=null`,\n`pagination.has_more=false` et `pagination.limit` égal au nombre de stages\nretournées.\n\nChaque stage est enrichie d'un compteur `contact_count` calculé en temps\nréel — nombre de contacts du client actuellement positionnés sur cette\nstage (utile pour les KPI de pipeline).\n",
        "required": [
          "id",
          "name",
          "slug",
          "color",
          "stage_order",
          "is_default",
          "is_won",
          "is_lost",
          "created_at",
          "contact_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "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.\nCalculé en temps réel à chaque appel — pas mis en cache.\n",
            "example": 17
          }
        }
      },
      "EmailConversation": {
        "type": "object",
        "description": "Thread email (`client_email_conversations`) entre le client Capturia et un\ncontact. Un thread agrège tous les messages échangés sur un même sujet\navec un même destinataire. Triés par `last_message_at DESC` (plus récent\nen premier).\n\nL'API publique expose la lecture seulement\n(`GET /v1/emails/conversations`) — l'envoi d'un email passe par\n`POST /v1/emails/send` (qui crée message + conversation côté serveur).\nLa consultation des messages individuels d'une conversation n'est pas\nencore exposée via l'API publique (à venir dans une phase ultérieure —\naujourd'hui le payload se limite aux métadonnées du thread).\n",
        "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\nn'a pas encore été matché à un `client_contacts` (email entrant inconnu\nou non-encore-réconcilié).\n"
          },
          "sender_id": {
            "type": "string",
            "format": "uuid",
            "description": "Membre de l'équipe client (`client_users`) qui a initié ou possède la\nconversation. Détermine la mailbox d'origine côté Resend.\n"
          },
          "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\ntypiques : `open`, `closed`, `archived`. Liste gérée côté plateforme —\nnon-enum ici pour permettre l'évolution.\n",
            "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).\nUtilisé comme clé de tri principal du thread. NOT NULL côté table.\n",
            "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\nest enquêté avec `status=\"queued\"`, `priority=1`, `type=\"transactional\"`\net délivré par le worker email côté Capturia (Resend) — la réponse 201\nconfirme la mise en queue, pas la délivrance finale.\n\nL'expéditeur (`from`) est résolu serveur-side depuis le sender par\ndéfaut du client (`client_email_senders.is_default = true`) — pas\naccepté depuis le body. Si aucun sender par défaut n'est configuré :\n400 `no_sender_configured`.\n\nL'`Idempotency-Key` n'est PAS supporté sur cette route — un retry\nnaïf créera un second envoi.\n",
        "required": [
          "to",
          "subject",
          "body"
        ],
        "additionalProperties": false,
        "properties": {
          "to": {
            "type": "string",
            "format": "email",
            "description": "Destinataire de l'email. Validé contre le pattern email standard\n(`^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`). Un format invalide retourne\n400 `validation_error`.\n",
            "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 —\nla sanitization est effectuée par le worker d'envoi avant\ndélivrance Resend.\n",
            "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\nopérationnel : les variables de personnalisation (`{{first_name}}`,\netc.) sont interpolées avec des valeurs d'exemple, le sujet est\npréfixé `[TEST]`, et les vérifications de conformité destinataire\n(désabonnement, consentement) sont contournées.\n\nDeux garde-fous s'appliquent en contrepartie :\n\n- le destinataire (`to`) doit être un membre actif de l'équipe du\n  client — sinon 400 `recipient_not_allowed` ;\n- un quota de 50 envois de test par 24 h glissantes, partagé avec\n  les boutons de test de la plateforme — sinon 429\n  `test_quota_exceeded`.\n\nUn envoi de test ne crée ni conversation ni historique sur un\ncontact.\n",
            "example": false
          }
        }
      },
      "EmailSendResult": {
        "type": "object",
        "description": "Réponse 201 de `POST /v1/emails/send`. L'email est en queue —\n`status=\"queued\"` à la création. La délivrance finale (ouverture, bounce,\netc.) est tracée séparément côté worker email et n'est pas exposée par\ncet endpoint.\n",
        "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).\n",
            "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).\n",
            "example": false
          }
        }
      },
      "Webhook": {
        "type": "object",
        "description": "Webhook sortant — endpoint HTTPS du client appelé par Capturia quand un\névénement souscrit survient. Un webhook créé via l'API appartient à la\nfois au client ET à la clé API qui l'a créé : `GET /v1/webhooks` ne montre\nque les webhooks de la clé courante (ceux créés par une autre clé du même\nclient, ou depuis le tableau de bord, ne sont ni visibles ni supprimables\nici).\n\nLe secret HMAC utilisé pour signer les payloads sortants est uniquement\nretourné à la création (`POST`) puis chiffré en base. Il ne peut pas être\nrelu par la suite — pour le faire tourner, supprimer le webhook et en\ncréer un nouveau.\n",
        "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\nplus livrés à l'URL). La désactivation se fait côté plateforme — pas\nd'endpoint API publique pour basculer ce flag.\n",
            "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\ndoit utiliser HTTPS — HTTP est rejeté pour éviter de fuiter les payloads\nen clair. Maximum 10 souscriptions actives par client (au-delà :\n429 `subscription_limit_reached`).\n",
        "required": [
          "url",
          "events"
        ],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "URL HTTPS qui recevra les notifications. Doit être un endpoint\naccessible publiquement et accepter `POST` JSON.\n",
            "example": "https://hooks.example.com/capturia"
          },
          "events": {
            "type": "array",
            "description": "Liste non-vide d'événements métier à souscrire, ou `*` pour tous les\névénements. Le nom de chaque événement est livré tel quel dans l'entête\n`X-Capturia-Event` et le champ `event` du payload. Catalogue complet et\nschéma des payloads : `docs/webhooks-event-catalog.md`.\n",
            "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\nclair, retourné UNE SEULE FOIS à la création. Le client doit le stocker\nimmédiatement : il sert à vérifier la signature des payloads sortants\n(`X-Capturia-Signature`) et n'est plus jamais retourné par l'API.\n",
        "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).\nStocké chiffré côté serveur — non récupérable après cette\nréponse. Utilisé pour signer les payloads sortants.\n",
                "example": "8f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c38f3b1a2c9d4e4f5ab6c7d8e9f0a1b2c3"
              }
            }
          }
        ]
      },
      "PresuasionSubmissionAnswer": {
        "type": "object",
        "description": "Réponse formatée à une question de l'outil pré-suasion. Sert à\nconstruire le bloc humain visible dans la timeline du contact côté\ndashboard Capturia (`Q: ...` / `R: ...`).\n",
        "required": [
          "question",
          "answer"
        ],
        "additionalProperties": false,
        "properties": {
          "question": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "example": "Quel est ton défi principal en ventes ?"
          },
          "answer": {
            "type": "string",
            "maxLength": 2000,
            "example": "Je perds des leads parce que je ne suis pas assez rapide à les rappeler."
          }
        }
      },
      "PresuasionSubmissionPayload": {
        "type": "object",
        "description": "Body de soumission d'un formulaire pré-suasion\n(`POST /v1/presuasion-submissions`).\n\n**Convention legacy — endpoint d'intégration interne, ne suit pas les\nconventions v1.** Cet endpoint est appelé par la gateway pré-suasion\n(`demo.capturia.io`) quand un visiteur complète un formulaire d'outil\npré-suasion. Il n'utilise pas de clé API Bearer (route publique\nrate-limitée par IP), accepte un body au format custom et retourne un\nbody au format custom (`{success, contact_id, ...}` au lieu de\n`{data: ...}`).\n\nLe client cible est résolu serveur-side via `proposition_slug` →\n`presuasion_instances.client_id` (le `client_id` n'est jamais accepté\ndepuis le body). Le contact est upserté par `(lower(email), client_id)`\navec enrichissement additif (les champs existants non-null ne sont\njamais écrasés).\n",
        "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`).\nSert à résoudre le client Capturia cible — le client_id n'est\njamais lu depuis le body.\n",
            "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\nquand `identity_confirmed: true` est accompagné d'un `token` valide —\nle contact est alors résolu par le lien tokenisé (règle appliquée\nserver-side, non exprimable en JSON Schema : sans email ni\nidentité confirmée par token, le serveur répond 400).\n",
            "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\ndans la colonne structurée `client_contacts.address` de façon additive :\nne remplit la colonne que si elle est encore vide (jamais d'écrasement).\n",
            "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\ntelles quelles dans `metadata.responses` de l'activité créée.\n",
            "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\nhumain visible dans la timeline du contact.\n",
            "items": {
              "$ref": "#/components/schemas/PresuasionSubmissionAnswer"
            }
          },
          "result_data": {
            "type": "object",
            "additionalProperties": true,
            "description": "Données calculées par l'outil pré-suasion (ex score, recommandation,\ncatégorie). Stockées telles quelles dans `metadata.result_data`.\n"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Horodatage de complétion côté gateway. Si absent, le serveur utilise\n`now()`.\n",
            "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\nde démos (`demo.capturia.io` et domaines custom unifiés). Vérifié\nserver-side via siteverify avec `idempotency_key` égal au\n`submission_id` du body (stable entre les retries — un token est à\nusage unique, Cloudflare rejoue le verdict original sur la même clé),\nrepli sur le `request_id` de la requête si `submission_id` est absent.\nOptionnel en mode log-only ; obligatoire quand\n`PRESUASION_TURNSTILE_REQUIRED=true` (réponse 403 sinon).\n"
          },
          "submission_id": {
            "type": "string",
            "format": "uuid",
            "description": "Clé d'idempotence générée par la gateway, stable à travers les retries\nd'une même soumission. Si une activité `presuasion_submission` existe\ndéjà avec ce `submission_id` pour le client résolu, le serveur retourne\nl'activité existante — avant tout décompte de quota et toute écriture —\nau lieu d'en créer une seconde (évite les doublons sur retry\ntransitoire, ex. timeout proxy après succès serveur). L'unicité est\ngarantie en base par un index unique partiel : deux soumissions\nconcurrentes portant le même `submission_id` produisent une seule\nactivité.\n",
            "example": "b3f1c2a4-5d6e-4f70-8a91-2c3d4e5f6071"
          },
          "token": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Token du lien pré-suasion personnalisé (`presuasion_link_tokens`),\ntransmis par la gateway quand le visiteur arrive par un lien envoyé à\nun contact connu. Sert à l'attribution token-first du contact et au\ncycle de vie du lien. Jamais utilisé après un `identity_declined`.\n",
            "example": "tok_abcdefabcdefabcdefabcdef"
          },
          "session_key": {
            "type": "string",
            "format": "uuid",
            "description": "Clé anonyme de la visite (`presuasion_tool_sessions.session_key`),\ngénérée côté runner. Permet de rattacher la soumission à sa visite :\nrejeu du reniement, complétion de la session, et trace\n`metadata.session_id` sur l'activité (cohorte du funnel analytics).\nAbsent = soumission sans suivi de visite (lead « non rattaché »).\n",
            "example": "6f1f5e0a-2b3c-4d5e-8f9a-0b1c2d3e4f5a"
          },
          "identity_confirmed": {
            "type": "boolean",
            "description": "Le visiteur a confirmé être le destinataire du lien tokenisé\n(« C'est bien moi »). Avec `token`, le contact est résolu par le lien\net `email` devient optionnel. Mutuellement exclusif avec\n`identity_declined` (400 sinon).\n",
            "example": true
          },
          "identity_declined": {
            "type": "boolean",
            "description": "Le visiteur a déclaré ne PAS être le destinataire du lien\n(« Ce n'est pas moi »). Le token n'attribue jamais le contact, le\nreniement est rejoué server-side avant la complétion, et `email`\nredevient obligatoire. Mutuellement exclusif avec\n`identity_confirmed` (400 sinon).\n",
            "example": false
          },
          "custom_domain_id": {
            "type": "string",
            "description": "Identifiant du custom domain (`client_domains.id`) résolu server-side par\nle proxy gateway à partir du host de la soumission. Sert UNIQUEMENT à\ndésambiguïser la proposition quand un même slug actif existe sur plusieurs\nbuckets de domaine. Absent = bucket partagé (demo.capturia.io,\n`custom_domain_id IS NULL`). Jamais utilisé pour résoudre le client_id\n(toujours dérivé de la proposition côté serveur).\n",
            "example": "a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809"
          }
        }
      },
      "PresuasionSubmissionResponse": {
        "type": "object",
        "description": "Réponse de `POST /v1/presuasion-submissions` (200 OK).\n\n**Convention legacy** — body custom non aligné avec la convention v1\n`{data: ...}`. Top-level `success` boolean + champs scalaires.\n`contact_created` indique si le contact a été créé (`true`) ou\nseulement enrichi (`false`).\n",
        "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\nexistant a été enrichi (les champs absents ont été remplis, les\nchamps déjà présents n'ont jamais été écrasés).\n",
            "example": false
          },
          "activity_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifiant de l'activité de type `presuasion_submission` créée\ndans la timeline du contact.\n"
          }
        }
      },
      "PresuasionSubmissionError": {
        "type": "object",
        "description": "Body d'erreur des réponses 4xx/5xx de `POST /v1/presuasion-submissions`.\n\n**Convention legacy** — body custom non aligné avec\n`ErrorEnvelope` du catalog v1. `error` est une chaîne lisible\n(pas d'objet `{code, message, ...}`) et `issues` n'est présent que\npour les erreurs de validation Zod (400 `Invalid payload`).\n",
        "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,\nou est encore en cours de traitement.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "example": {
              "error": {
                "type": "idempotency_error",
                "code": "idempotency_conflict",
                "message": "Une requête avec cette Idempotency-Key existe avec un payload différent.",
                "hint": "Utilise une nouvelle Idempotency-Key, ou rejoue la requête originale à l'identique.",
                "doc_url": "https://capturia.io/fr/developers/api/v1/conventions#idempotency",
                "request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                "details": {
                  "idempotency_key": "01J9X8Z3F4K2M5N7P9Q1R3S5T7",
                  "original_request_id": "req_01J9X8Z3F4K2M5N7P9Q1R3S5T6"
                }
              }
            }
          }
        }
      },
      "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\nrenvoie la response d'origine sans dupliquer l'opération. Stockée 24h. Optionnel mais recommandé.\n"
      },
      "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."
      }
    }
  }
}
