openapi: 3.1.0

# ════════════════════════════════════════════════════════════════════════════
# Source de vérité de l'API publique CreaScale.
#
# Avant le 2026-08-04, l'API n'était décrite QUE dans une page React derrière
# l'authentification (src/pages/ApiDocs.tsx), en deux copies divergentes : celle servie en
# ligne ne mentionnait plus /v1/ugc/videos, pourtant ouvert aux plans Pro. Un client payait
# donc un endpoint documenté nulle part où il pouvait le lire. Ce fichier est versionné avec
# le code : une route ajoutée sans son entrée ici fait échouer test_templates_e2e.sh.
# ════════════════════════════════════════════════════════════════════════════

info:
  title: CreaScale API
  version: "1.0.0"
  description: |
    API REST de CreaScale : génération publicitaire (vidéo créateur, statiques, campagnes) et
    messagerie WhatsApp (templates, texte libre, conversations, consentement, événements de livraison).

    **Plans.** L'API exige un abonnement actif. Les plans Pro et Scale atteignent
    `/v1/ugc/*`, `/v1/static-ads`, `/v1/credits` **et l'API messaging WhatsApp COMPLÈTE**
    (`/v1/templates`, `/v1/messages/*`, `/v1/optin/*` depuis le 2026-08-11, plus
    `/v1/webhooks` et `/v1/events` depuis le 2026-08-14 : sans eux on enverrait sans jamais
    apprendre la réponse du client) ; le reste — les campagnes — est réservé à l'Enterprise. Le plafond mensuel d'envois du plan
    (10 000 Pro · 100 000 Scale · illimité fair-use Enterprise) s'applique AUSSI par l'API :
    au-delà, `POST /v1/messages/template` répond 409 `monthly_cap_reached`. Le plan Free n'ouvre
    pas l'API ; un compte retombé en Free en cours de mois (abonnement `past_due` / `canceled`)
    dont la clé est encore honorée peut toutefois recevoir 409 `free_cap_reached` sur cette même
    route : plafond mensuel du plan Free atteint, réponse commerciale (passer au payant), réarmement
    le 1er du mois.

    **Limite de débit.** 60 requêtes par minute et par clé. C'est la seule limite appliquée :
    ni plafond journalier, ni tolérance de rafale.

    Guide d'intégration, vérification de signature et stratégie de réconciliation :
    voir `docs/api/README.md`.

servers:
  # ⚠️ DEUX HÔTES, ET C'EST MESURÉ — pas déduit. Cette spec décrit deux produits servis par deux
  # services distincts ; se tromper d'hôte donne un 404 « Endpoint not found » parfaitement
  # trompeur (le routeur répond, il ne connaît simplement pas la route).
  #
  #   • TEMPLATES WhatsApp (/templates, /messages, /optin, /events) → creascale-agent-api
  #   • Pipeline PUBLICITAIRE (/runs, /ugc, /static-ads, /credits, /webhooks) → api.creascale.ai
  #
  # La répartition se lit sur le PRÉFIXE DE CHEMIN (ci-dessus), pas sur un `servers:` par
  # opération : OpenAPI ne permet pas d'exprimer « ce serveur pour ces tags » autrement qu'en
  # dupliquant le bloc sur chaque opération, ce qui serait 31 copies à maintenir. Le premier
  # hôte est en tête parce que c'est celui du lot Enterprise décrit ici.
  #
  # ⚠️ `https://creascale.ai/api/v1/...` ne sert NI l'un NI l'autre : il renvoie **200 avec le
  # HTML de la SPA** (le proxy du front ne couvre que /api/agent et /api/connect). Un client qui
  # suivrait cette URL parserait du HTML en croyant lire du JSON, sans jamais voir d'erreur.
  #
  # 📌 RESTE OWNER : l'agent-api n'a AUCUN domaine personnalisé (vérifié via l'API Render le
  # 2026-08-04). Poser `agent-api.creascale.ai` dessus donnerait une URL présentable à un client
  # Enterprise ; tant que ce n'est pas fait, c'est l'hôte Render qui fait foi.
  - url: https://creascale-agent-api.onrender.com/api/v1
    description: Templates WhatsApp (Pro+) — service agent
  - url: https://api.creascale.ai/api/v1
    description: Pipeline publicitaire (runs, UGC, static ads, crédits, webhooks) — service vidéo

security:
  - bearerAuth: []

tags:
  - name: Templates WhatsApp
    description: |
      Catalogue de templates, envoi hors fenêtre 24 h, et consentement.

      Deux niveaux de consentement, non interchangeables : un template `utility` ou
      `authentication` se contente d'un opt-in actif (la commande vaut consentement) ; un
      template `marketing` exige un consentement **explicite** — le fait que le client ait
      écrit un jour ne compte pas. Un envoi marketing non consenti fait signaler le message,
      et WhatsApp finit par couper le numéro.
  - name: Consentement
  - name: Conversations
    description: |
      Le rail d'un CRM tiers (Pro+) : répondre en texte libre dans la fenêtre 24 h et relire les
      fils. Chaque texte envoyé **prend la main** sur la conversation (l'agent IA se tait) — rendez-la
      avec `POST /conversations/{id}/resolve`, sinon l'agent reste muet à vie sur ce fil.
  - name: Événements
  - name: Webhooks
  - name: Campagnes
  - name: Vidéo UGC
  - name: Publicités statiques
  - name: Crédits

paths:
  # ── Templates WhatsApp ────────────────────────────────────────────────────
  /templates:
    get:
      tags: [Templates WhatsApp]
      summary: Liste le catalogue de templates
      description: Lecture locale du miroir alimenté par la synchronisation. Aucun appel réseau.
      responses:
        "200":
          description: Catalogue
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PlanRequired" }
    post:
      tags: [Templates WhatsApp]
      summary: Crée un template chez Meta
      description: |
        Le template naît `pending` : Meta le revoit de façon asynchrone. Le verdict arrive par
        le webhook `template.approved` / `template.rejected`.

        Si le corps contient des variables `{{1}}`, `{{2}}`…, `body_examples` est **obligatoire**
        et doit en contenir autant — Meta refuse sinon, 24 h plus tard, sans motif exploitable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, language, category, body]
              properties:
                name:
                  type: string
                  description: Minuscules, chiffres et tirets bas uniquement.
                  example: order_confirmation
                language: { type: string, example: fr }
                category:
                  type: string
                  enum: [utility, marketing, authentication]
                body: { type: string, example: "Salam {{1}}, ta commande {{2}} est prête." }
                body_examples:
                  type: array
                  items: { type: string }
                  example: ["Yassine", "8832"]
                footer: { type: string, example: "STOP pour ne plus recevoir." }
                buttons:
                  type: array
                  maxItems: 3
                  description: |
                    Boutons de réponse rapide, dans l'ordre — l'index compte.

                    ⚠️ Seul le libellé se déclare ici : Meta n'accepte pas le payload à la
                    création. Le payload se fixe à l'ENVOI (`quick_reply_payloads`). Sans lui,
                    la réponse du client porte le LIBELLÉ — du texte traduit — et vous devriez
                    le comparer dans chaque langue que vous servez.
                  items:
                    type: object
                    required: [text]
                    properties:
                      text: { type: string, maxLength: 25, example: "Confirmer" }
      responses:
        "201":
          description: Template soumis à Meta
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string }
                  language: { type: string }
                  status: { type: string, example: pending }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: Aucun canal WhatsApp connecté sur ce compte.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PlanRequired" }

  /templates/sync:
    post:
      tags: [Templates WhatsApp]
      summary: Resynchronise le catalogue depuis le WABA
      description: |
        Lecture chez Meta. N'envoie aucun message et ne peut déclencher aucune sanction.
        Un template disparu chez Meta passe en `disabled` localement — jamais supprimé, pour
        que l'historique des envois garde sa référence.
      responses:
        "200":
          description: Synchronisation effectuée
          content:
            application/json:
              schema:
                type: object
                properties:
                  synced: { type: integer }
                  warning:
                    type: string
                    description: |
                      Présent si le catalogue a été tronqué (`max_pages_reached`). On le dit
                      plutôt que de laisser croire à un catalogue complet.
        "409": { description: Aucun canal WhatsApp connecté, ou WABA inconnu. }
        "502": { description: Meta injoignable ou a refusé la lecture. }

  # ── Réglages de l'onglet Templates ────────────────────────────────────────
  # Déclaré AVANT /templates/{name} : `config` est un chemin réservé, pas un nom de template.
  /templates/config:
    get:
      tags: [Templates WhatsApp]
      summary: Lit les réglages d'automation
      description: |
        Les réglages de l'onglet Templates du cockpit : quel template part à la confirmation de
        commande, lequel part à l'expédition, les textes de relance et la surveillance de Sheet.

        Les champs préfixés `readonly_` sont **lisibles mais non écrivables** : ce sont des droits
        de plan (`shared/plans.ts`), pas des réglages.

        Disponible uniquement sur `https://creascale-agent-api.onrender.com` — pas sur
        `api.creascale.ai`, qui sert le pipeline publicitaire.
      responses:
        "200":
          description: Réglages effectifs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatesConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PlanRequired" }
    patch:
      tags: [Templates WhatsApp]
      summary: Écrit les réglages d'automation
      description: |
        Corps = **delta**. Une clé absente n'est pas touchée ; `null` efface le réglage.

        La réponse est l'état **relu en base**, pas l'écho de la requête : un refus partiel est
        donc visible sans second appel.

        Toute clé hors de la liste ci-dessous est **refusée** (`400 unknown_fields`), jamais
        ignorée en silence — un réglage cru enregistré mais absent ferait taire la confirmation
        de commande sans le dire. Les réglages d'identité de l'agent (prompt, encaissement,
        escalade, e-mail de contact) restent au cockpit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                order_confirm_template:
                  type: string
                  nullable: true
                  description: Template utility envoyé à la création d'une commande Shopify.
                  example: order_confirmation
                order_confirm_text:
                  type: string
                  nullable: true
                  description: Texte libre de confirmation (rail non-officiel). Max 1024.
                shipping_confirm_template:
                  type: string
                  nullable: true
                  description: Template envoyé quand la commande passe `fulfilled`.
                order_followup_text:
                  type: string
                  nullable: true
                  description: Relance générique après réponse du client (fenêtre 24 h).
                followup_confirmed_text:
                  type: string
                  nullable: true
                  description: Relance si le client a touché le bouton de confirmation.
                followup_cancelled_text:
                  type: string
                  nullable: true
                  description: Relance si le client a touché le bouton d'annulation.
                sheet_watch_enabled:
                  type: boolean
                  description: Surveillance d'un Google Sheet — nouvelle ligne, envoi automatique.
                sheet_watch_url:
                  type: string
                  nullable: true
                  description: |
                    Lien Google Sheet partagé en lecture. Seul l'hôte `docs.google.com` est
                    accepté ; toute autre URL est refusée (`400 invalid_values`).
                  example: https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQr/edit#gid=0
                sheet_watch_template:
                  type: string
                  nullable: true
                  description: Template envoyé aux nouvelles lignes du Sheet.
                sheet_watch_map:
                  type: object
                  nullable: true
                  description: |
                    Mapping de colonnes. Fourni par vous — l'API ne le devine pas (le cockpit le
                    calcule via son propre analyseur). Sans lui, le balayage retombe sur
                    l'heuristique et peut lire les mauvaises colonnes.
                  properties:
                    phone_idx: { type: integer, minimum: 0, maximum: 200 }
                    var_idx:
                      type: array
                      maxItems: 3
                      items: { type: integer, minimum: 0, maximum: 200 }
                    header: { type: boolean }
      responses:
        "200":
          description: Réglages après écriture (relus en base)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplatesConfig" }
        "400":
          description: |
            `unknown_fields` (clé hors périmètre, listée dans `fields`) ·
            `invalid_values` (valeur refusée, clé nommée dans `fields`) · `empty_patch`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: unknown_fields }
                  fields:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PlanRequired" }

  /templates/{name}:
    delete:
      tags: [Templates WhatsApp]
      summary: Supprime un template
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Supprimé chez Meta, marqué `disabled` localement. }
        "400": { description: "`reserved_name` — `config` est le chemin des réglages." }
        "409": { description: Aucun canal WhatsApp connecté. }

  # ── Envoi ─────────────────────────────────────────────────────────────────
  /messages/template:
    post:
      tags: [Templates WhatsApp]
      summary: Envoie un template
      description: |
        Met l'envoi en file et répond **202** : au moment de la réponse, rien n'est encore
        parti chez Meta. Un worker draine la file ; suivez l'état par `GET /messages/{send_id}`
        ou par webhook.

        `Idempotency-Key` est **obligatoire**. Rejouer la même clé rend toujours le même
        `send_id` avec `duplicate: true`. Sans elle, un rejeu réseau enverrait deux fois le
        même message au même client — ce que WhatsApp compte comme du spam.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, maxLength: 255 }
          description: Identifiant de l'acte métier (ex. `order-8832-confirmation`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, template, language]
              properties:
                to: { type: string, description: "Numéro E.164 sans « + »", example: "212600000001" }
                template: { type: string, example: order_confirmation }
                language: { type: string, example: fr }
                body_params:
                  type: array
                  items: { type: string }
                  maxItems: 10
                  description: |
                    Paramètres positionnels du corps, **dans l'ordre des `{{1}}`, `{{2}}`…**.
                    Un tableau PLAT de chaînes. Aucune entrée ne peut être vide.

                    ⚠️ **Ce n'est PAS la forme `components` de l'API Graph de Meta.** Un corps
                    contenant `components` est refusé en **400 `unknown_fields`** : cette API
                    n'accepte que des paramètres texte (ni `currency`, ni `date_time`, ni
                    en-tête média, ni carrousel), et accepter la forme sans le contenu vous
                    renverrait un 202 pour un envoi qui échouerait chez Meta une minute plus
                    tard, sans que vous le sachiez.

                    ⚠️ **Le NOMBRE doit correspondre au template.** Trop ou trop peu de
                    paramètres est refusé en **400 `body_params_count_mismatch`**, qui vous
                    rend le compte attendu. Vous n'avez donc pas à le deviner.
                header_params: { type: array, items: { type: string }, maxItems: 10 }
                button_url_param: { type: string, description: Suffixe d'URL dynamique du bouton. }
                quick_reply_payloads:
                  type: array
                  items: { type: string }
                  example: ["CONFIRM:8832", "CANCEL:8832"]
                  description: |
                    Ce que le client renverra en cliquant, dans l'ordre des boutons du template.

                    **C'est la seule valeur indépendante de la langue.** Sans elle, WhatsApp
                    renvoie le libellé du bouton (« Confirmer », « تأكيد »), et votre
                    intégration devrait comparer du texte traduit — ce qui casse à la première
                    langue ajoutée, et casse en silence. Utilisez des valeurs neutres et
                    stables, idéalement porteuses de la référence métier.

                    Une entrée vide saute son bouton sans décaler les index des suivants.
      responses:
        "202":
          description: Accepté et mis en file
          content:
            application/json:
              schema:
                type: object
                properties:
                  send_id: { type: string, format: uuid }
                  status: { type: string, example: queued }
                  duplicate:
                    type: boolean
                    description: "`true` = cette clé avait déjà été vue ; aucun second envoi n'a lieu."
                  service_window_minutes_left:
                    type: integer
                    description: |
                      Minutes restantes avant la fermeture de la fenêtre 24 h. `0` = fermée,
                      c'est-à-dire le cas où un template est la seule option.
        "400":
          description: |
            La REQUÊTE est à corriger (par opposition au 409, où c'est l'ÉTAT).
            Aucune clé d'idempotence n'est consommée : rejouez la requête corrigée à
            l'identique, avec la même clé.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum:
                      - unknown_fields
                      - invalid_field
                      - invalid_body
                      - body_params_count_mismatch
                  message: { type: string }
                  unknown_fields:
                    type: array
                    items: { type: string }
                    description: "Sur `unknown_fields` : les clés refusées, dans l'ordre reçu."
                  expected_body_params:
                    type: integer
                    description: "Sur `body_params_count_mismatch` : ce que le template attend."
                  received_body_params: { type: integer }
              examples:
                forme_graph_refusee:
                  summary: la forme `components` de Meta n'est pas acceptée
                  value:
                    error: unknown_fields
                    unknown_fields: [components]
                    message: >-
                      Unknown field(s): "components". Accepted: "to", "template", "language",
                      "body_params", "header_params", "button_url_param",
                      "quick_reply_payloads".
                arite:
                  summary: le template attend 6 variables, 3 fournies
                  value:
                    error: body_params_count_mismatch
                    expected_body_params: 6
                    received_body_params: 3
        "409":
          description: |
            Requête valide, mais l'état actuel l'interdit. Ce n'est **jamais** une erreur de
            syntaxe : ne modifiez pas la requête, corrigez l'état.

            Plafonds mensuels : `monthly_cap_reached` = volume du plan atteint, ça repart le 1er
            du mois. `free_cap_reached` = plafond mensuel du plan **Free** atteint — réponse
            commerciale (passer à un plan payant pour continuer tout de suite), sinon réarmement
            le 1er du mois. L'API n'est pas ouverte au plan Free : ce motif n'est atteignable que
            par un compte payant retombé en Free en cours de mois, tant que sa clé est encore
            honorée (le plan de la clé est mis en cache 5 min ; le plafond, lui, est lu en direct).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum:
                      - templates_disabled
                      - template_unknown
                      - template_not_approved
                      - optin_missing
                      - marketing_optin_missing
                      - daily_cap_reached
                      - monthly_cap_reached
                      - free_cap_reached
                      - outside_send_hours
                      - no_whatsapp_channel
                  message: { type: string }
                  service_window_minutes_left: { type: integer }

  /messages/{send_id}:
    get:
      tags: [Templates WhatsApp]
      summary: Statut d'un envoi
      parameters:
        - { name: send_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: État courant
          content:
            application/json:
              schema:
                type: object
                properties:
                  send_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued, sent, delivered, read, failed] }
                  provider_message_id: { type: [string, "null"] }
                  error: { type: [string, "null"] }
                  created_at: { type: string, format: date-time }
        "404":
          description: |
            Envoi inexistant — ou appartenant à un autre compte. Les deux cas rendent 404 :
            révéler l'existence d'une ressource d'un tiers est déjà une fuite.

  /messages/text:
    post:
      tags: [Conversations]
      summary: Envoie un texte libre (fenêtre 24 h)
      description: |
        Réponse humaine depuis votre CRM, **dans la fenêtre de 24 h** ouverte par le dernier message
        du client. Hors fenêtre, Meta refuse (code 131047) : vous recevez **409 `outside_service_window`**
        avec l'erreur Meta verbatim — envoyez un template à la place.

        **Takeover.** Chaque texte envoyé ici passe la conversation en mode humain : l'agent IA
        **se tait** sur ce fil jusqu'à `POST /conversations/{id}/resolve`. Un CRM qui n'appelle
        jamais `/resolve` garde l'agent muet à vie sur ce client.

        **Quota.** Compté sur le plafond mensuel du plan (Pro 10 000 · Scale 100 000 · Enterprise
        illimité), messages humains sortants cockpit + API confondus. Aucun crédit débité.
        Rafale : 30 requêtes/min par clé, sous le 60/min global.

        `Idempotency-Key` est acceptée mais **non appliquée** sur le texte (aucune déduplication) :
        un rejeu réseau envoie deux fois. Conservez le `message_id` rendu.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [to, text]
                  properties:
                    to: { type: string, description: "Numéro E.164 (avec ou sans « + »), ou IGSID sur Instagram", example: "212600000001" }
                    text: { type: string, maxLength: 900 }
                    channel: { type: string, enum: [whatsapp, instagram], default: whatsapp }
                - type: object
                  required: [conversation_id, text]
                  properties:
                    conversation_id: { type: string, format: uuid }
                    text: { type: string, maxLength: 900 }
      responses:
        "200":
          description: Message accepté par Meta et persisté
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TextSendResult" }
        "400":
          description: |
            `invalid_body` · `to_or_conversation_id_required` · `invalid_conversation_id` ·
            `invalid_channel` · `empty_text` · `text_too_long` (900 caractères).
        "404": { description: "`conversation_not_found` — `conversation_id` inexistant ou d'un autre compte." }
        "409":
          description: |
            Requête valide, état interdit. Pas de `free_cap_reached` sur cette route : le plan est
            jugé en amont (`plan_required`) et le plafond opposé est celui du plan de la clé — ce
            motif n'existe que sur `POST /messages/template`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum:
                      - no_conversation
                      - plan_required
                      - monthly_cap_reached
                      - outside_service_window
                      - conversation_closed
                      - channel_disabled
                      - channel_unsupported
                      - channel_missing
                      - customer_unknown
                      - token_missing
                  message: { type: string }
                  cap: { type: [integer, "null"], description: "Sur `monthly_cap_reached`" }
                  used: { type: integer, description: "Sur `monthly_cap_reached`" }
                  meta_error: { type: [string, "null"], description: "Sur `outside_service_window` : le texte Meta verbatim" }
                  meta_code: { type: [integer, "null"] }
        "429": { description: "30 textes/min par clé dépassés." }
        "500": { description: "`usage_read_failed` — le compteur mensuel n'a pas pu être lu ; rien n'est parti." }
        "502":
          description: Meta a refusé l'envoi pour un autre motif que la fenêtre 24 h
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetaRefusal" }

  /messages/media:
    post:
      tags: [Conversations]
      summary: Envoie une photo, une vidéo, un document ou un audio (fenêtre 24 h)
      description: |
        Le média part **par son lien** : vous donnez une URL HTTPS publiquement joignable, et c'est
        **Meta** qui va la chercher. Aucun octet ne transite par CreaScale, et vous n'avez rien à
        téléverser. Le lien doit rester joignable le temps que Meta le lise.

        **Fenêtre 24 h.** Un média est un message de service, exactement comme un texte libre :
        hors de la fenêtre ouverte par le dernier message du client, Meta refuse (code 131047) et
        vous recevez **409 `outside_service_window`**. Envoyez un template à la place.

        **Takeover.** Chaque média envoyé ici passe la conversation en mode humain : l'agent IA
        **se tait** sur ce fil jusqu'à `POST /conversations/{id}/resolve`.

        **Quota.** Compté sur le **même** plafond mensuel que `/messages/text` (Pro 10 000 ·
        Scale 100 000 · Enterprise illimité), cockpit et API confondus. Aucun crédit débité.
        Rafale : 30 requêtes/min par clé.

        **Ce que Meta accepte, par type** — la légende n'existe que sur `image` et `video`, le nom
        de fichier que sur `document` (et il y est **obligatoire**, sinon votre client voit une URL
        tronquée). Les combinaisons interdites sont refusées ici, avec leur motif, plutôt que de
        vous rendre un `(#100) Invalid parameter` illisible.

        | type | taille max Meta | `caption` | `filename` |
        |---|---|---|---|
        | `image` | 5 Mo | oui | non |
        | `video` | 16 Mo | oui | non |
        | `audio` | 16 Mo | non | non |
        | `document` | 100 Mo | non | **obligatoire** |

        `Idempotency-Key` est acceptée mais **non appliquée** (aucune déduplication) : un rejeu
        réseau envoie deux fois. Conservez le `message_id` rendu.

        **WhatsApp uniquement.** `channel: "instagram"` rend `400 invalid_channel` : Instagram
        n'accepte ni document, ni audio, ni légende — servir la moitié du contrat sans le dire
        serait pire que le refuser.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [to, type, link]
                  properties:
                    to: { type: string, description: "Numéro E.164 (avec ou sans « + »)", example: "212600000001" }
                    type: { type: string, enum: [image, video, document, audio] }
                    link: { type: string, format: uri, maxLength: 2048, example: "https://cdn.exemple.com/bijoux/bague-nour.jpg" }
                    caption: { type: string, maxLength: 900, description: "image et vidéo seulement" }
                    filename: { type: string, maxLength: 240, description: "document seulement, obligatoire" }
                    channel: { type: string, enum: [whatsapp], default: whatsapp }
                - type: object
                  required: [conversation_id, type, link]
                  properties:
                    conversation_id: { type: string, format: uuid }
                    type: { type: string, enum: [image, video, document, audio] }
                    link: { type: string, format: uri, maxLength: 2048 }
                    caption: { type: string, maxLength: 900 }
                    filename: { type: string, maxLength: 240 }
      responses:
        "200":
          description: Média accepté par Meta et persisté dans le fil
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TextSendResult" }
        "400":
          description: |
            `invalid_body` · `to_or_conversation_id_required` · `invalid_conversation_id` ·
            `invalid_channel` (Instagram non servi) · `invalid_media_type` · `invalid_link`
            (HTTPS obligatoire) · `caption_not_supported` · `caption_too_long` (900) ·
            `filename_not_supported` · `filename_required` · `filename_too_long` (240).
        "404": { description: "`conversation_not_found` — `conversation_id` inexistant ou d'un autre compte." }
        "409":
          description: |
            Requête valide, état interdit. Pas de `free_cap_reached` sur cette route : le plan est
            jugé en amont (`plan_required`) et le plafond opposé est celui du plan de la clé — ce
            motif n'existe que sur `POST /messages/template`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum:
                      - no_conversation
                      - plan_required
                      - monthly_cap_reached
                      - outside_service_window
                      - conversation_closed
                      - channel_disabled
                      - channel_unsupported
                      - channel_missing
                      - customer_unknown
                      - token_missing
                  message: { type: string }
                  cap: { type: [integer, "null"], description: "Sur `monthly_cap_reached`" }
                  used: { type: integer, description: "Sur `monthly_cap_reached`" }
                  meta_error: { type: [string, "null"], description: "Sur `outside_service_window` : le texte Meta verbatim" }
                  meta_code: { type: [integer, "null"] }
        "429": { description: "30 médias/min par clé dépassés." }
        "500": { description: "`usage_read_failed` — le compteur mensuel n'a pas pu être lu ; rien n'est parti." }
        "502":
          description: |
            Meta a refusé pour un autre motif que la fenêtre 24 h — le plus souvent un lien qu'il
            n'a pas pu lire (404, authentification requise, type non supporté). L'erreur Meta est
            rendue verbatim.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MetaRefusal" }

  /messages/media/{handle}:
    get:
      tags: [Conversations]
      summary: Télécharge un média reçu d'un client
      description: |
        Rend **les octets** du fichier envoyé par votre client. Le `handle` vous est donné par le
        champ `media[].url` du webhook `message.received` : rejouez cette URL telle quelle, avec
        votre clé API.

        **Pourquoi ce relais existe.** L'URL que Meta rend pour un média **expire en 5 minutes**
        et exige le jeton de votre compte WhatsApp Business : vous ne pouvez donc pas télécharger
        le fichier vous-même. CreaScale va le chercher pour vous, **en mémoire**, et vous le rend
        sans jamais en garder de copie.

        ⚠️ **C'est une fenêtre, pas une archive.** Meta expire un identifiant de média reçu par
        webhook au bout de **7 jours**, et CreaScale ne stocke aucun média : **téléchargez le
        fichier à réception du webhook et conservez-le chez vous**. Passé ce délai, la réponse est
        `410 media_expired`, et il n'existe aucun moyen de le récupérer.

        La réponse est le fichier brut, avec son `Content-Type` d'origine et
        `Content-Disposition: attachment`. WhatsApp uniquement.
      parameters:
        - name: handle
          in: path
          required: true
          schema: { type: string }
          description: "Le jeton opaque du webhook, par exemple `wa_1234567890123456`."
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [image, video, document, audio, sticker] }
          description: |
            Le `media[].type` du webhook. Facultatif : il ne sert qu'à appliquer la borne de taille
            la plus stricte **avant** le téléchargement. Sans lui, la borne large (100 Mo) s'applique.
      responses:
        "200":
          description: Les octets du média
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "400":
          description: |
            `invalid_handle` (jeton malformé — rejouez l'URL du webhook sans la modifier) ·
            `channel_unsupported_for_media` (jeton Instagram : ce canal n'est pas servi).
        "409": { description: "`channel_missing` (aucun WhatsApp connecté) · `token_missing` (jeton expiré, reconnectez le numéro)." }
        "410": { description: "`media_expired` — Meta ne sert plus ce média : les identifiants reçus par webhook expirent après 7 jours." }
        "413": { description: "`media_too_large` — au-dessus de la limite Meta pour ce type." }
        "429": { description: "60 téléchargements/min par clé dépassés." }
        "502": { description: "`media_fetch_failed` — Meta n'a pas rendu le média. Réessayez." }

  # ── Consentement ──────────────────────────────────────────────────────────
  /optin/import:
    post:
      tags: [Consentement]
      summary: Importe des consentements marketing
      description: |
        Les trois champs `origin`, `collected_at` et `consent_text` sont **obligatoires** : une
        déclaration sans origine ne prouve rien. Les entrées incomplètes sont rejetées une par
        une, avec leur motif — l'import n'est pas tout-ou-rien.

        Un client ayant envoyé **STOP** n'est jamais réactivé par un import (`opted_out`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entries]
              properties:
                entries:
                  type: array
                  maxItems: 5000
                  items:
                    type: object
                    required: [customer, origin, collected_at, consent_text]
                    properties:
                      customer: { type: string, example: "212600000001" }
                      origin: { type: string, example: "case cochée au checkout" }
                      collected_at: { type: string, format: date-time }
                      consent_text: { type: string, example: "J'accepte de recevoir les offres" }
      responses:
        "200":
          description: Résultat de l'import
          content:
            application/json:
              schema:
                type: object
                properties:
                  imported: { type: integer }
                  rejected:
                    type: array
                    items:
                      type: object
                      properties:
                        customer: { type: string }
                        reason:
                          type: string
                          enum: [customer_required, origin_required, collectedAt_required, consentText_required, opted_out]

  /optin/{customer}:
    get:
      tags: [Consentement]
      summary: État du consentement d'un client
      parameters:
        - { name: customer, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: État
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer: { type: string }
                  has_optin:
                    type: boolean
                    description: Consentement actif — suffit pour un template `utility`.
                  has_marketing_optin:
                    type: boolean
                    description: Consentement explicite — exigé pour un template `marketing`.
                  source: { type: [string, "null"], enum: [cod-submit, double-optin, import, manual, inbound, null] }
                  revoked_at: { type: [string, "null"], format: date-time }
    delete:
      tags: [Consentement]
      summary: Révoquer le consentement d'un client (honorer un STOP)
      description: |
        Honore un STOP reçu AILLEURS (téléphone, site, service client). Idempotent. La révocation
        est ABSORBANTE : elle est posée sur toutes les graphies du numéro (`wa_id` nu ET `+E.164`),
        donc aucun rail — automatique, diffusion ou API — ne peut la contourner.
        Envoyer à un désabonné est le vecteur n°1 de bannissement d'un WABA.
      parameters:
        - { name: customer, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Consentement révoqué
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer: { type: string }
                  revoked: { type: boolean }
                  has_optin: { type: boolean }
                  revoked_at: { type: [string, "null"], format: date-time }

  # ── Événements ────────────────────────────────────────────────────────────
  /events:
    get:
      tags: [Événements]
      summary: Réconciliation des envois
      description: |
        Le webhook donne la latence, ce poll donne la garantie. Les webhooks sont
        *at-least-once* et peuvent être perdus si votre endpoint est indisponible ; ce endpoint
        rattrape sans qu'on ait à rejouer pour vous. Repartez de `next_since` au prochain appel.
      parameters:
        - { name: since, in: query, required: true, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      responses:
        "200":
          description: Envois depuis `since`
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SendEvent" }
                  next_since: { type: string, format: date-time }
        "400": { description: "`since` absent ou non ISO-8601." }

  # ── Conversations (Pro+) ──────────────────────────────────────────────────
  /conversations:
    get:
      tags: [Conversations]
      summary: Liste des conversations (inbox)
      description: |
        Les conversations du compte, la plus récente d'abord, avec le contact **non masqué** (c'est
        votre client) et l'aperçu du dernier message. Repartez de `next_since` au prochain appel :
        seules les conversations touchées depuis sont rendues.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: channel, in: query, schema: { type: string, enum: [whatsapp, instagram] } }
        - { name: since, in: query, schema: { type: string, format: date-time }, description: "Ne rendre que `last_message_at >= since`" }
      responses:
        "200":
          description: Conversations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Conversation" }
                  next_since: { type: string, format: date-time }
        "400": { description: "`since` non ISO-8601." }

  /conversations/counts:
    get:
      tags: [Conversations]
      summary: Compteurs réels
      responses:
        "200":
          description: Totaux (jamais la taille d'une page)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConversationCounts" }

  /conversations/{id}/messages:
    get:
      tags: [Conversations]
      summary: Le fil d'une conversation
      description: |
        Les messages du plus ancien au plus récent. `from` vaut `customer`, `agent` (l'IA) ou
        `human` (votre équipe, cockpit ou API). Repartez de `next_after`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: after, in: query, schema: { type: string, format: date-time }, description: "Ne rendre que `created_at > after`" }
      responses:
        "200":
          description: Messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ConversationMessage" }
                  next_after: { type: string, format: date-time }
        "400": { description: "`id` non UUID, ou `after` non ISO-8601." }

  /conversations/{id}/resolve:
    post:
      tags: [Conversations]
      summary: Rend le fil à l'agent IA
      description: |
        Termine le mode humain ouvert par `POST /messages/text` (ou par une réponse cockpit) :
        l'agent IA reprend la conversation. **Obligatoire pour un CRM** — sans cet appel l'agent
        reste muet à vie sur ce client. Idempotent.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Fil rendu à l'agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation_id: { type: string, format: uuid }
                  state: { type: string, enum: [agent] }
        "404": { description: "`conversation_not_found`" }

  # ── Webhooks ──────────────────────────────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: Liste les webhooks (secrets masqués)
      responses:
        "200": { description: Liste }
    post:
      tags: [Webhooks]
      summary: Enregistre un webhook
      description: |
        HTTPS obligatoire. Le `secret` n'est renvoyé **qu'une fois** : conservez-le, il sert à
        vérifier la signature de chaque livraison. Maximum 5 webhooks par compte.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "201":
          description: Créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  secret: { type: string, description: Affiché une seule fois. }
                  events: { type: array, items: { type: string } }
        "400": { description: URL invalide, non-HTTPS, ou maximum de 5 atteint. }

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Supprime un webhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: Supprimé }
        "404": { description: Introuvable }

  # ── Campagnes (Enterprise) ────────────────────────────────────────────────
  /runs:
    get:
      tags: [Campagnes]
      summary: Historique des runs
      responses: { "200": { description: Liste } }
    post:
      tags: [Campagnes]
      summary: Lance un run (nouveau pipeline ou relance)
      responses: { "201": { description: Run créé } }
  /runs/{id}:
    get:
      tags: [Campagnes]
      summary: Détail d'un run
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: Run } }
  /runs/{id}/angles:
    get:
      tags: [Campagnes]
      summary: Angles d'un run
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: Angles } }
  /runs/{id}/angles/add:
    post:
      tags: [Campagnes]
      summary: Ajoute des angles
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: Ajoutés } }
  /runs/{id}/angles/batch-duplicate:
    post:
      tags: [Campagnes]
      summary: Duplique plusieurs angles
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: Dupliqués } }
  /runs/{id}/angles/{angle_id}:
    put:
      tags: [Campagnes]
      summary: Modifie un angle
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: angle_id, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: Modifié } }
    delete:
      tags: [Campagnes]
      summary: Supprime un angle
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: angle_id, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: Supprimé } }
  /runs/{id}/angles/{angle_id}/duplicate:
    post:
      tags: [Campagnes]
      summary: Duplique un angle
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: angle_id, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: Dupliqué } }
  /runs/{id}/generate:
    post:
      tags: [Campagnes]
      summary: Lance la génération des visuels
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: Génération lancée } }
  /runs/{id}/media/{media_id}/url:
    get:
      tags: [Campagnes]
      summary: URL signée d'un média
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: media_id, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: URL } }
  /runs/{id}/media/{media_id}/ai-edit:
    post:
      tags: [Campagnes]
      summary: Retouche IA d'un média
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: media_id, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: Média retouché } }

  # ── Vidéo UGC (Pro+) ──────────────────────────────────────────────────────
  /ugc/videos:
    get:
      tags: [Vidéo UGC]
      summary: Liste les vidéos
      responses: { "200": { description: Liste } }
    post:
      tags: [Vidéo UGC]
      summary: Génère une vidéo créateur
      responses: { "202": { description: Génération lancée } }
  /ugc/videos/{id}:
    get:
      tags: [Vidéo UGC]
      summary: État d'une vidéo
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Vidéo } }
  /ugc/batch/{batch_id}:
    get:
      tags: [Vidéo UGC]
      summary: État d'un lot de vidéos
      parameters: [{ name: batch_id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Lot } }

  # ── Publicités statiques (Pro+) ───────────────────────────────────────────
  /static-ads:
    post:
      tags: [Publicités statiques]
      summary: Génère des visuels statiques
      responses: { "202": { description: Génération lancée } }
  /static-ads/{run_id}:
    get:
      tags: [Publicités statiques]
      summary: État d'une génération
      parameters: [{ name: run_id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: État } }

  # ── Crédits (Pro+) ────────────────────────────────────────────────────────
  /credits:
    get:
      tags: [Crédits]
      summary: Solde de crédits
      responses: { "200": { description: Solde } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Clé API `cs_live_…` créée dans le cockpit (Réglages → Clés API)."

  responses:
    Unauthorized:
      description: Clé absente, invalide ou révoquée.
    PlanRequired:
      description: |
        Votre plan ne couvre pas cet endpoint. Pro et Scale atteignent `/ugc/*`,
        `/static-ads`, `/credits` et l'API messaging WhatsApp (`/templates`, `/messages/*`,
        `/optin/*`, `/webhooks`, `/events`) ; le reste (campagnes) est Enterprise.
    BadRequest:
      description: Requête malformée.

  schemas:
    Template:
      type: object
      properties:
        name: { type: string }
        language: { type: string }
        category: { type: string, enum: [utility, marketing, authentication] }
        status:
          type: string
          enum: [pending, approved, rejected, paused, disabled]
          description: Seul `approved` autorise un envoi.
        body: { type: [string, "null"] }
        variableCount: { type: integer }
        rejectedReason:
          type: [string, "null"]
          description: Motif du refus, tel que Meta le formule. N'existe que dans le webhook d'approbation.
        syncedAt: { type: [string, "null"], format: date-time }

    TemplatesConfig:
      type: object
      description: |
        Réglages de l'onglet Templates. Les champs `readonly_*` sont des droits de plan, servis
        pour éviter d'écrire à l'aveugle — les modifier passe par un changement d'abonnement.
      properties:
        order_confirm_template: { type: [string, "null"] }
        order_confirm_text: { type: [string, "null"] }
        shipping_confirm_template: { type: [string, "null"] }
        order_followup_text: { type: [string, "null"] }
        followup_confirmed_text: { type: [string, "null"] }
        followup_cancelled_text: { type: [string, "null"] }
        sheet_watch_enabled: { type: boolean }
        sheet_watch_url: { type: [string, "null"] }
        sheet_watch_template: { type: [string, "null"] }
        sheet_watch_map:
          type: [object, "null"]
          properties:
            phoneIdx: { type: integer }
            varIdx: { type: array, items: { type: integer } }
            header: { type: boolean }
        readonly_templates_enabled:
          type: boolean
          description: Rail templates ouvert pour ce compte. Réglage propriétaire, pas marchand.
        readonly_template_daily_cap: { type: [integer, "null"] }
        readonly_template_hours:
          type: [object, "null"]
          properties:
            start: { type: integer }
            end: { type: integer }
        readonly_timezone: { type: string }
        readonly_plan: { type: string, example: pro }
        readonly_sends_monthly_cap: { type: [integer, "null"], description: "`null` = pas de plafond mensuel." }
        readonly_sends_lifetime_cap: { type: [integer, "null"] }
        readonly_templates_max: { type: [integer, "null"], description: "`null` = templates illimités (Pro et au-delà)." }

    TextSendResult:
      type: object
      properties:
        message_id: { type: [string, "null"], format: uuid }
        at: { type: string, format: date-time }
        conversation_id: { type: string, format: uuid, description: "Le fil pris en main — à rendre par `/conversations/{id}/resolve`" }
    MetaRefusal:
      type: object
      properties:
        error: { type: string, enum: [send_failed] }
        meta_error: { type: [string, "null"], description: Texte de l'erreur Meta, verbatim }
        meta_code: { type: [integer, "null"], description: "`error.code` Meta (ex. 190 = jeton expiré)" }
    Conversation:
      type: object
      properties:
        id: { type: string, format: uuid }
        customer: { type: string, description: "Numéro ou @handle — non masqué, c'est votre client" }
        channel: { type: string, enum: [whatsapp, instagram, web] }
        language: { type: [string, "null"] }
        state: { type: string, enum: [open, agent, human, closed], description: "`human` = un humain a la main (agent muet)" }
        last_message_at: { type: [string, "null"], format: date-time }
        last_preview: { type: string, maxLength: 90 }
        last_direction: { type: [string, "null"], enum: [inbound, outbound, null] }
    ConversationMessage:
      type: object
      properties:
        id: { type: string, format: uuid }
        from: { type: string, enum: [customer, agent, human] }
        text: { type: string }
        at: { type: string, format: date-time }
        direction: { type: [string, "null"], enum: [inbound, outbound, null] }
        status: { type: [string, "null"], enum: [sent, delivered, read, failed, null] }
    ConversationCounts:
      type: object
      properties:
        all: { type: integer }
        whatsapp: { type: integer }
        instagram: { type: integer }
        escalations: { type: integer, description: "Conversations en mode humain (takeover)" }
    SendEvent:
      type: object
      properties:
        send_id: { type: string, format: uuid }
        status: { type: string, enum: [queued, sent, delivered, read, failed] }
        provider_message_id: { type: [string, "null"] }
        error: { type: [string, "null"] }
        customer: { type: string }
        created_at: { type: string, format: date-time }
        sent_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        read_at: { type: [string, "null"], format: date-time }
        idempotency_key:
          type: [string, "null"]
          description: La clé `Idempotency-Key` posée à l'envoi (votre identifiant métier).
        answered_at:
          type: [string, "null"]
          format: date-time
          description: Quand le client final a répondu (clic de bouton ou texte). Null sinon.
        answer_payload:
          type: [string, "null"]
          description: >-
            Payload du bouton cliqué (ex. `CONFIRM:9342`) ou texte libre de la réponse. C'est le
            même champ que `button_payload` du webhook `message.received` — un poll `/events`
            suffit donc à rattraper une décision client dont le webhook a été perdu.

    WebhookEvent:
      type: string
      enum:
        - run.completed
        - run.failed
        - angles.ready
        - angles.added
        - angles.validated
        - images.completed
        - media.ai_edited
        - template.approved
        - template.rejected
        - message.accepted
        - message.delivered
        - message.read
        - message.failed
        - message.received
