openapi: 3.1.0
info:
  title: Yasmine API
  version: 1.1.0
  summary: Agent vocal IA pour confirmation de commandes — API reseller.
  description: |
    API Yasmine — déclenche des appels IA sortants pour confirmer des commandes,
    et gère crédits, webhooks, clés API en self-service.

    **Statut des endpoints** :
    - `x-status: live` — implémenté et appelable en prod aujourd'hui.
    - `x-status: internal` — route technique, pas sous le SLA `/v1/*`.

    **Format d'erreur** : toutes les erreurs sous `/v1/*` sont au format
    RFC 7807 `application/problem+json` — cf `docs/errors.md`.

    **Authentification** : Bearer token `yk_...` sur tous les endpoints
    `/v1/*`. Cf `docs/getting-started.md`.

    **Traçabilité** : chaque requête reçoit un header response
    `X-Request-ID` (UUID v4). Vous pouvez envoyer votre propre UUID v4
    via le header request de même nom — Yasmine le respecte si format
    strict valide, sinon régénère. Le `request_id` apparaît aussi dans
    le body des réponses d'erreur RFC 7807 et dans nos logs serveurs.
    Joignez-le à tout ticket support.

    **Headers response transverses** (émis sur toutes les responses `/v1/*`,
    succès comme erreur — non répétés par endpoint pour lisibilité) :

    - `X-Request-ID` (UUID v4) — cf bloc Traçabilité ci-dessus. Schéma :
      `#/components/headers/RequestIdResponse`.
    - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
      — émis sur les 2xx des endpoints rate-limités et aussi
      sur les 429. Schémas : `#/components/headers/RateLimitLimit`,
      `RateLimitRemaining`, `RateLimitReset`.
    - `Retry-After` (RFC 7231) — émis uniquement sur 429, voir
      `#/components/responses/RateLimited`.
    - `X-Idempotent-Replay` — uniquement sur `POST /v1/calls` quand la
      réponse est un replay, voir la route.

    Chaque endpoint `/v1/*` déclare son propre plafond rate-limit via
    l'extension `x-rate-limit: "N/minute"` (valeur source code, cf
    compteur par clé API).

    Voir aussi : `/docs/getting-started.md`, `/docs/examples.md`,
    `/docs/webhooks.md`, `/docs/errors.md`, `/docs/versioning.md`.
  x-common-headers:
    description: |
      Headers response communs — documentés globalement dans
      `info.description` pour éviter la duplication sur chaque response.
    items:
      - "$ref": "#/components/headers/RequestIdResponse"
      - "$ref": "#/components/headers/RateLimitLimit"
      - "$ref": "#/components/headers/RateLimitRemaining"
      - "$ref": "#/components/headers/RateLimitReset"
  contact:
    name: Yasmine API
    email: contact@akidly.com
    url: https://docs.yasmine.akidly.com
  license:
    name: Proprietary
servers:
  - url: https://api.yasmine.akidly.com
    description: Production
tags:
  - name: Calls
    description: Déclencher et consulter les appels IA sortants.
  - name: Account
    description: Profil reseller, solde, consommation, transactions, clés API, webhooks sortants.
  - name: Webhooks
    description: |
      Configuration self-service du webhook sortant unique par reseller.
      Émission des events (catalogue `call.*`) cf `docs/webhooks.md`.
  - name: API Keys
    description: |
      Gestion self-service des clés API du reseller. CRUD simple :
      liste, création (secret visible **une seule fois**), révocation
      soft. Pas de scopes, pas d'expiration automatique — la seule
      bascule sécurité est la révocation manuelle.
  - name: Shops
    description: |
      Fiches boutiques : déclarer, consulter, modifier, archiver. Déclarer
      une boutique ici évite de retransmettre son profil à chaque demande
      d'appel.
  - name: Products
    description: |
      Catalogue d'une boutique : produits et leurs declinaisons. Un produit
      declare ici se commande ensuite par simple reference — son nom, son
      prix et ses variantes n'ont plus a etre repetes a chaque commande.
  - name: Orders
    description: |
      Les commandes d'une boutique, declarees avec votre identifiant puis
      appelees par reference — autant de fois qu'il faut, les appels
      s'empilent sur la commande. Les articles sont figes a la declaration.
  - name: Internal
    description: |
      Routes techniques hors SLA `/v1/*` (probe santé). Ne sont **pas**
      destinées à l'intégration reseller — documentées pour transparence
      opérationnelle uniquement.
security:
  - bearerAuth: []
paths:
  # -------------------------------------------------------------------------
  # Calls — reseller-facing
  # -------------------------------------------------------------------------
  /v1/calls:
    post:
      operationId: createCall
      summary: Déclencher un appel
      tags: [Calls]
      x-status: live
      x-rate-limit: "60/minute"
      description: |
        Crée un appel IA sortant.

        **Le canal est choisi pour vous.** Omettez `call_params.channel` :
        le canal par défaut de votre installation s'applique, et c'est le
        bon dans la quasi-totalité des cas. Le préciser n'a d'intérêt que
        si vous avez une raison de forcer une voie particulière — et tous
        les canaux ne sont pas ouverts partout, un canal fermé renvoyant
        `422 channel_not_available`.

        **Deux façons de désigner la commande.** Décrite sur place, avec
        `customer` et `order` — la forme historique, inchangée. Ou **par
        référence**, avec `order_external_id` seul : la commande a été
        déclarée via `POST /v1/shops/{id}/orders`, le serveur relit le
        client, les articles figés et les montants, et rattache l'appel à
        la commande. C'est la forme à privilégier : relancer le client se
        fait avec la même référence, et « toutes les commandes de ce
        produit » a une réponse.

        **Idempotency-Key obligatoire** : header `Idempotency-Key`
        requis (1-255 chars, UUID v4 recommandé). Scope par reseller, TTL
        24 h. Un retry réseau (504 Cloudflare, time-out client) avec la
        même clé renvoie la réponse stockée bit-for-bit (`X-Idempotent-
        Replay: true`) sans déclencher d'appel supplémentaire. Réutiliser
        la même clé avec un body différent retourne `409
        idempotency_key_conflict`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/RequestIdHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallCreate"
            examples:
              maroc_basic:
                summary: Appel simple Maroc (commande à article unique, texte libre)
                value:
                  customer:
                    name: "Ahmed Bennani"
                    phone_number: "+212612345678"
                  shop_external_id: "boutique-casa-01"
                  shop_info:
                    name: "GlowArt Casa"
                  order:
                    external_id: "CMD-2026-00789"
                    delivery_address: "12 Rue X, Casablanca"
                    items_text: "Crème hydratante visage 50ml"
                    amount: "249.00"
                    currency: "MAD"
                  country: "MA"
                  call_params:
                    purpose: "confirmation"
              maroc_with_items:
                summary: Appel Maroc avec liste structurée d'articles
                value:
                  customer:
                    name: "Ahmed Bennani"
                    phone_number: "+212612345678"
                  shop_external_id: "boutique-casa-01"
                  shop_info:
                    name: "GlowArt Casa"
                  order:
                    external_id: "CMD-2026-00790"
                    delivery_address: "12 Rue X, Casablanca"
                    items:
                      - name: "T-shirt blanc en coton"
                        quantity: 2
                        variants:
                          - name: "Taille"
                            value: "XXL"
                          - name: "Couleur"
                            value: "blanc"
                        available_variants:
                          - - name: "Taille"
                              value: "XXL"
                            - name: "Couleur"
                              value: "blanc"
                          - - name: "Taille"
                              value: "XL"
                            - name: "Couleur"
                              value: "blanc"
                          - - name: "Taille"
                              value: "M"
                            - name: "Couleur"
                              value: "noir"
                        description: "T-shirt col rond 100% coton, coupe droite."
                        unit_price: "120.00"
                      - name: "Jean noir slim"
                        quantity: 1
                        unit_price: "320.00"
                    amount: "560.00"
                    currency: "MAD"
                  country: "MA"
                  call_params:
                    purpose: "confirmation"
              maroc_french_speaker:
                summary: Appel Maroc avec client francophone (override de la langue par défaut)
                value:
                  customer:
                    name: "Sophie El Idrissi"
                    phone_number: "+212661111222"
                  shop_external_id: "boutique-casa-01"
                  shop_info:
                    name: "GlowArt Casa"
                  order:
                    external_id: "CMD-2026-00791"
                    delivery_address: "12 Rue X, Casablanca"
                    items_text: "Crème hydratante visage 50ml"
                    amount: "249.00"
                    currency: "MAD"
                  country: "MA"
                  language: "fr"
                  call_params:
                    purpose: "confirmation"
              france_full:
                summary: Appel France, profil client + shop + order complets
                value:
                  customer:
                    name: "Claire Dupont"
                    phone_number: "+33612345678"
                    gender: "female"
                    email: "claire@example.com"
                    notes: "VIP, horaires bureau uniquement"
                  shop_external_id: "boutique-paris-01"
                  shop_info:
                    name: "GlowArt Paris"
                    sector: "beauté"
                    return_policy: "retour gratuit sous 14 jours"
                  order:
                    external_id: "CMD-2026-00123"
                    delivery_address: "25 Av des Champs-Élysées, 75008 Paris"
                    items_text: "sérum éclat 30ml + crème de jour"
                    amount: "49.90"
                    currency: "EUR"
                    delivery_method: "express"
                    delivery_zone: "urban"
                    source: "web"
                  country: "FR"
                  call_params:
                    purpose: "retry"
                    tone: "friendly"
                    voice_id: "coral"
                  metadata:
                    invoice_ref: "INV-789"
      responses:
        "201":
          description: |
            Appel créé et mis en file. Si `X-Idempotent-Replay: true` est
            présent dans les headers, la réponse est un replay d'une
            requête antérieure identique (même clé d'idempotence + même
            body), aucun nouvel appel n'a été déclenché.
          headers:
            Location:
              schema: { type: string }
              example: /v1/calls/dcaebcdd-a81a-4386-a0df-85d9aaafe862
            X-Idempotent-Replay:
              description: |
                Présent uniquement sur un replay idempotent. Vaut
                `"true"`. Absent sur la première occurrence d'une clé.
              schema: { type: string, enum: ["true"] }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CallOut"
        "400": { $ref: "#/components/responses/IdempotencyKeyError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientBalance" }
        "409": { $ref: "#/components/responses/IdempotencyKeyConflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      operationId: listCalls
      summary: Lister les appels
      tags: [Calls]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Liste paginée des appels du reseller authentifié.
        Tri `created_at DESC` avec tie-break stable `id DESC`.

        **Curseur opaque** : passer la valeur `next_cursor` de la page
        précédente en `?cursor=...`. Le curseur est signé HMAC-SHA256
        côté serveur (TTL 24 h) — format opaque pour le client, pas
        de structure à parser.

        **Filtres combinables** : `status`, `shop_id`, `since`,
        `until`. Tous optionnels.

        **Erreurs de curseur** : `400 invalid_cursor` (format invalide,
        signature tampered, ou clé serveur tournée) ou `400
        cursor_expired` (> 24 h). Le client refait la 1re requête
        sans `cursor`.
      parameters:
        - in: query
          name: cursor
          required: false
          schema: { type: string }
          description: |
            Curseur opaque retourné par `next_cursor` de la page
            précédente. Absent pour la première page.
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Nombre max d'items par page. > 200 → 422.
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [queued, dialing, in_progress, ended, failed]
          description: |
            Filtre exact sur le statut de l'appel, dérivé de
            `request_status` et `call_status`.
            Mapping :
            - `queued` — en attente de permission (`request_status` parmi
              `queued` / `waiting_user_permission`).
            - `dialing` — permission acceptée, numérotation en cours
              (`call_status` parmi `not_started`/`dialing`/`ringing`).
            - `in_progress` — client décroché (`call_status = connected`).
            - `ended` — fin naturelle (`call_status = ended`).
            - `failed` — tout état terminal non-nominal : permission
              refusée, expirée, quota bloqué, annulé reseller, ou tout
              autre échec non mappé. Un cancel reseller termine ici et
              non dans un `cancelled` distinct côté statut agrégé.
        - in: query
          name: result
          required: false
          schema:
            type: string
            enum: [confirmed, cancelled, requires_action]
          description: |
            Filtre exact sur le résultat principal métier. Exclut les
            appels en cours (`result` `null`). Combinable avec `status`
            pour cibler par exemple `?status=ended&result=confirmed`
            (commandes confirmées sans souci).
        - in: query
          name: shop_id
          required: false
          schema: { type: string, format: uuid }
        - in: query
          name: since
          required: false
          schema: { type: string, format: date-time }
          description: Minimum `created_at` (inclusif).
        - in: query
          name: until
          required: false
          schema: { type: string, format: date-time }
          description: Maximum `created_at` (inclusif).
      responses:
        "200":
          description: Page paginée (enveloppe `data`/`next_cursor`/`has_more`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedCalls" }
        "400":
          description: |
            Curseur invalide/expiré (slug `invalid_cursor` ou
            `cursor_expired`). Refaire la 1re requête sans `cursor`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: |
            clé de signature absente côté serveur (fail-closed).
            Contacter le support.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/calls/{call_id}:
    get:
      operationId: getCall
      summary: Récupérer un appel
      tags: [Calls]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Lit l'état courant d'un appel scopé au reseller authentifié
        Utile pour réconcilier après un webhook perdu ou un
        crash mid-retry.

        **Anti-énumération** : trois cas renvoient un 404
        `call_not_found` byte-identique :
        - UUID syntaxiquement invalide (`/v1/calls/not-a-uuid`).
        - UUID valide mais inconnu.
        - UUID propriété d'un autre reseller.

        Aucun champ ne fuite l'existence ou le propriétaire.
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Détails de l'appel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/CallNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/calls/{call_id}/recording:
    get:
      operationId: getCallRecording
      summary: Télécharger l'enregistrement audio d'un appel
      tags: [Calls]
      x-status: live
      x-rate-limit: "60/minute"
      description: |
        Stream le mix audio (client + agent) d'un appel terminé en WAV
        mono 16 kHz, 16-bit PCM.

        **Disponibilité** : seulement après que l'appel a atteint un état
        terminal (`call_status=ended`). Le champ `recording_url` du
        `CallOut` (cf `GET /v1/calls/{call_id}`) indique la disponibilité —
        `null` si l'audio n'est pas (encore) prêt.

        **Rétention** : 30 jours après la fin de l'appel. Au-delà, le
        fichier est purgé et l'endpoint renvoie `410 Gone` avec le slug
        `recording_gone` (distinct du `404 call_not_found` pour permettre
        au reseller de différencier « jamais existé » vs « purgé »).

        **Anti-énumération** : quatre cas renvoient un 404
        `call_not_found` byte-identique :
        - UUID syntaxiquement invalide
        - UUID inconnu
        - UUID propriété d'un autre reseller
        - artifact pas encore créé (call jamais finalisé)

        **Format** : WAV non compressé, mono, 16 kHz. ~32 KB/s soit
        ~2 MB pour 60s. Header `Content-Disposition: attachment;
        filename="<call_id>.wav"`.

        **Rate limit** : 60 req/min — plus restrictif que les endpoints
        JSON (audio plus lourd, on protège la bande passante). Cacher
        localement côté reseller.
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Stream WAV (mono, 16 kHz, 16-bit PCM).
          headers:
            Content-Disposition:
              schema: { type: string }
              description: 'attachment; filename="<call_id>.wav"'
          content:
            audio/wav:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/CallNotFound" }
        "410":
          description: |
            L'enregistrement a été purgé après la fenêtre de rétention
            (30 jours).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/calls/{call_id}/cancel:
    post:
      operationId: cancelCall
      summary: Annuler un appel en cours
      tags: [Calls]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Annule un appel **en cours** (rail APPEL en `dialing`/`ringing`/
        `connected`, ou demande encore `queued`). Pattern Stripe-like sur
        ressource vivante.

        **Facturation** :
        - Avant `connected` → `billed_seconds = 0`, aucun débit.
        - `connected` → `billed_seconds = ceil(now - accepted_at)` avec
          plancher minimum 10 s (cf `docs/pricing.md`). Débit immédiat.

        **Idempotence** : si l'appel est déjà terminal (`ended`/`failed`/
        `cancelled`), réponse `200 OK` no-op : pas de mutation DB, pas
        d'event webhook ré-émis, pas de débit.

        **Event sortant** : `call.cancelled` émis une seule fois sur cancel
        effectif. Distinct de `call.ended` (fin naturelle) — cf
        `docs/webhooks.md` §7.

        **Scoping** : si le `call_id` appartient à un autre reseller, 404
        `call_not_found` (pas de leak d'existence).
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Appel annulé (ou déjà terminal, idempotent).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallCancelOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/CallNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # -------------------------------------------------------------------------
  # Account — profil, solde, usage, clés, webhooks sortants
  # -------------------------------------------------------------------------
  /v1/me:
    get:
      operationId: getMe
      summary: Identité du reseller courant
      tags: [Account]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Retourne les 5 champs d'identité du reseller authentifié.
        Anti-BOPLA : pas de `balance_seconds` (voir `/v1/me/balance`),
        pas de `status` interne, pas de compteurs.
      responses:
        "200":
          description: Identité complète.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me/transactions:
    get:
      operationId: listTransactions
      summary: Ledger paginé (topup, debit_call, refund, adjustment)
      tags: [Account]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Historique paginé du ledger `credit_transactions`.
        Tri `created_at DESC, id DESC` (tie-break stable). Curseur
        opaque HMAC-SHA256 (TTL 24 h) — même
        contrat d'erreur `invalid_cursor` / `cursor_expired`.

        `seconds` est **signé** : positif = crédit (topup/refund),
        négatif = débit (debit_call). Pas de `currency` — la devise
        métier est "secondes facturables" aujourd'hui.
      parameters:
        - in: query
          name: cursor
          required: false
          schema: { type: string }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: since
          required: false
          schema: { type: string, format: date-time }
        - in: query
          name: until
          required: false
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Page paginée.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedTransactions" }
        "400":
          description: |
            Curseur invalide/expiré (`invalid_cursor` / `cursor_expired`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: |
            clé de signature absente côté serveur.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/me/usage:
    get:
      operationId: getUsage
      summary: Consommation mensuelle agrégée
      tags: [Account]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Agrégats sur la période `YYYY-MM`. Défaut = mois
        courant UTC si `?period` absent. Fenêtre semi-open
        `[period_start, period_end)` sur la date de création de l'appel (date de
        soumission, pas de fin).

        **Ventilation** basée sur `call_status` :
        - `ended` → `breakdown.completed_*`
        - `failed` → `breakdown.failed_calls`
        - `cancelled` → `breakdown.cancelled_calls`
        - in-flight (`queued/dialing/ringing/connected/not_started`)
          comptés uniquement dans `total_calls`.

        Période sans appel → **200 avec zéros partout** (pas 404).
      parameters:
        - in: query
          name: period
          required: false
          schema:
            type: string
            pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          example: "2026-04"
          description: Défaut = mois courant UTC si absent.
      responses:
        "200":
          description: Agrégats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageOut" }
        "400":
          description: |
            Format `period` invalide (`invalid_period_format`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me/api-keys:
    get:
      operationId: listApiKeys
      summary: Lister les clés API (sans le secret)
      tags: [API Keys]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Retourne les clés API du reseller courant. Inclut les
        clés actives ET révoquées (historique complet). Tri
        `created_at DESC`. Jamais de pagination — en pratique un
        reseller a < 10 clés.

        **Anti-BOPLA strict** : ni `key_hash`, ni `secret_key`, seul le
        `key_prefix` (12 chars) est exposé.
      responses:
        "200":
          description: Liste des clés.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyListOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createApiKey
      summary: Créer une nouvelle clé API
      tags: [API Keys]
      x-status: live
      x-rate-limit: "5/minute"
      description: |
        Génère une nouvelle clé API. Pattern Stripe/GitHub :
        la `secret_key` est **visible UNE SEULE FOIS** dans la réponse
        201. Le serveur n'en persiste que le hash SHA-256.

        **Rate-limit spécifique 5/min** (cumulé avec 600/min global).
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyCreateIn" }
            example:
              label: "ci-prod"
      responses:
        "201":
          description: |
            Clé créée. `secret_key` visible UNE FOIS ici — à stocker
            immédiatement côté reseller.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreatedOut" }
              example:
                id: "3f8e9d12-4b5a-4c6d-8e7f-123456789abc"
                key_prefix: "yk_Xa7bC3dE5f"
                label: "ci-prod"
                created_at: "2026-04-21T12:00:00Z"
                secret_key: "yk_Xa7bC3dE5fG9hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/me/api-keys/{key_id}:
    delete:
      operationId: revokeApiKey
      summary: Révoquer une clé API
      tags: [API Keys]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Soft-delete : pose `revoked_at = NOW()` sur la clé. La
        clé apparaîtra toujours dans `GET /v1/me/api-keys` avec
        `status=revoked` (historique préservé).

        **Anti-énumération** : 404 `api_key_not_found`
        byte-identique sur 3 cas : UUID malformé, clé inexistante, clé
        appartenant à un autre reseller.

        Auto-révocation safe : si le reseller révoque la clé qu'il est
        en train d'utiliser, la requête courante se termine normalement,
        la suivante retournera 401.
      parameters:
        - in: path
          name: key_id
          required: true
          schema: { type: string }
          description: |
            UUID v4 de la clé. Format invalide → 400 `invalid_key_id`.
      responses:
        "204":
          description: Clé révoquée.
        "400":
          description: |
            `key_id` n'est pas un UUID valide (`invalid_key_id`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: |
            Clé inexistante OU appartenant à un autre reseller
            (`api_key_not_found`, body byte-identique — anti-énum).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/me/api-keys/{key_id}/events:
    get:
      operationId: listApiKeyEvents
      summary: Journal d'audit d'une clé API
      tags: [API Keys]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Renvoie les événements associés à une clé API. Trois
        types d'events sont émis par le serveur aujourd'hui :

        - `created` — à la création via `POST /v1/me/api-keys`.
        - `revoked` — à la révocation via `DELETE /v1/me/api-keys/{id}`
          (jamais fire si la révocation retourne 404).
        - `rate_limited` — à chaque réponse 429 de l'API. **Dédupliqué
          côté serveur** : un seul event par clé par fenêtre de 60 s
          (garde-fou contre un attaquant qui saturerait l'endpoint).

        **Pagination** (curseur opaque HMAC-SHA256, TTL 24 h, forward-only).
        Tri `created_at DESC, id DESC`. `limit` max 50.

        **Anti-énumération** : 404 `api_key_not_found`
        byte-identique si la clé n'existe pas OU appartient à un autre
        reseller.

        **Anti-BOPLA** : `api_key_id` et `reseller_id` ne sont pas
        exposés dans la réponse (déjà dans l'URL et le scope tenant).
      parameters:
        - in: path
          name: key_id
          required: true
          schema: { type: string }
          description: UUID v4 de la clé. Format invalide → 400 `invalid_key_id`.
        - in: query
          name: cursor
          required: false
          schema: { type: string }
          description: Opaque, obtenu via `next_cursor` de la page précédente.
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 50 }
      responses:
        "200":
          description: Page d'events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyEventListOut" }
        "400":
          description: |
            `key_id` invalide (`invalid_key_id`) OU curseur corrompu/expiré
            (`invalid_cursor` / `cursor_expired`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: |
            Clé inexistante OU appartenant à un autre reseller
            (`api_key_not_found`, body byte-identique — anti-énum).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: "Clé de signature des curseurs non configurée côté serveur."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/me/balance:
    get:
      operationId: getBalance
      summary: Solde courant du reseller
      tags: [Account]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Solde du reseller authentifié, exprimé en **secondes facturables**
        Un appel coûte son `billed_seconds` (`max(duration_s, 10)`).

        Le solde peut être négatif dans la limite d'un découvert
        autorisé (300 s par défaut) — protection anti-clé volée.

        `currency` est `null` aujourd'hui (balance en secondes pures),
        réservé pour une future facturation en monnaie. `updated_at` est
        l'horodatage du dernier mouvement ledger.
      responses:
        "200":
          description: Solde courant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BalanceOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me/webhooks/test:
    post:
      operationId: triggerWebhookTest
      summary: Tester son webhook (synchrone)
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "10/minute"
      description: |
        Déclenche un `webhook.test` synchrone vers l'URL configurée du
        reseller. Retourne immédiatement le résultat (HTTP status,
        latence, erreur éventuelle). **Timeout 5 s, pas de retry**
        (contrairement aux events réels du dispatcher).

        Signature HMAC-SHA256 identique aux webhooks réels :
        `X-Yasmine-Signature: sha256=<hex>`. Le payload contient
        `data.test=true` pour permettre au reseller de filtrer ces
        événements en dev.

        **Rate-limit 10/min** spécifique (cumulé avec 600/min global).
        Toute tentative est tracée dans `webhook_deliveries` avec
        `event_type='webhook.test'` (visible via `GET /webhooks/deliveries`).
      responses:
        "200":
          description: Test exécuté (succès ou échec — voir `delivered`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookTestOut" }
        "400":
          description: |
            `webhook_url_not_configured` — pas de webhook actif. Configurer
            d'abord via `POST /v1/me/webhooks`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/me/webhooks/deliveries:
    get:
      operationId: listWebhookDeliveries
      summary: Historique paginé des livraisons webhook
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Audit des livraisons webhook sortantes du reseller.
        Tri `attempted_at DESC, id DESC`. Curseur opaque.

        **Anti-BOPLA strict** : ni `response_body`, ni `signature_sent`,
        ni `event_id` brut. `error_message` tronqué à 200 chars.

        Statuts disponibles via `?status=` :
        - `delivered` : `delivered_at IS NOT NULL` (2xx reçu).
        - `failed` : `error IS NOT NULL` ou status_code non-2xx.
        - `pending` : ni l'un ni l'autre (edge case).
      parameters:
        - in: query
          name: cursor
          required: false
          schema: { type: string }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [delivered, failed, pending]
        - in: query
          name: event_type
          required: false
          schema: { type: string }
          example: "call.ended"
        - in: query
          name: since
          required: false
          schema: { type: string, format: date-time }
        - in: query
          name: until
          required: false
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Page paginée.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedDeliveries" }
        "400":
          description: |
            `invalid_status_filter` (status hors `delivered`/`failed`/
            `pending`), `invalid_cursor`, ou `cursor_expired`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: |
            clé de signature absente côté serveur.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/me/webhooks:
    post:
      operationId: createWebhook
      summary: Configurer le webhook sortant
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "10/minute"
      description: |
        Enregistre l'URL du webhook sortant et génère un secret HMAC-SHA256.

        Le **`secret` est affiché UNE SEULE FOIS** dans cette réponse. Il
        sert à vérifier la signature `X-Yasmine-Signature` des payloads
        sortants (cf `docs/webhooks.md` §5). À stocker immédiatement côté
        reseller — aucune route ne permet de le récupérer ensuite.

        1 seul webhook actif par reseller. Pour rotate le secret :
        `DELETE /v1/me/webhooks` puis `POST` à nouveau.

        **SSRF guard** : l'URL est rejetée 400 si elle pointe vers
        `localhost`, un TLD `.local`, une IP privée (RFC 1918, loopback,
        link-local, ULA IPv6) ou utilise un scheme autre que `https`.

        **Note** : l'émission réelle des events (`call.ended`, etc.) est
        livrée en Phase 2 de C6. Configurer le webhook aujourd'hui ne
        déclenche encore aucun trafic.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreateInput" }
            examples:
              prod:
                summary: Endpoint HTTPS public
                value:
                  url: "https://monshop.com/yasmine-webhooks"
      responses:
        "201":
          description: Webhook créé — `secret` affiché UNE fois en clair.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "400":
          description: URL rejetée par le SSRF guard.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/WebhookUrlRejectedProblem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: Webhook déjà configuré. DELETE d'abord pour rotate.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              example:
                type: https://docs.yasmine.akidly.com/errors/webhook_already_configured
                title: Conflict
                status: 409
                detail: Ce reseller a deja un webhook actif. DELETE d'abord pour rotate volontairement le secret, puis POST a nouveau.
                instance: /v1/me/webhooks
                request_id: a1b2c3d4
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      operationId: getWebhook
      summary: Consulter la config webhook
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Retourne la config active. Le `secret` n'est jamais renvoyé —
        seul `secret_prefix` (6 chars + `…`) est exposé pour permettre
        au reseller de repérer quel secret est en place.
      responses:
        "200":
          description: Config active du reseller.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookGet" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Aucun webhook configuré.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              example:
                type: https://docs.yasmine.akidly.com/errors/webhook_not_configured
                title: Not Found
                status: 404
                detail: Aucun webhook configure pour ce reseller.
                instance: /v1/me/webhooks
                request_id: a1b2c3d4
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteWebhook
      summary: Désactiver le webhook
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "10/minute"
      description: |
        Soft-delete : la config passe en `active=false`. L'historique
        `webhook_deliveries` reste intact (pour audit).
      responses:
        "204": { description: Webhook désactivé. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Aucun webhook configuré.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              example:
                type: https://docs.yasmine.akidly.com/errors/webhook_not_configured
                title: Not Found
                status: 404
                detail: Aucun webhook configure pour ce reseller.
                instance: /v1/me/webhooks
                request_id: a1b2c3d4
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/me/webhooks/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      summary: Rotate le signing secret HMAC
      tags: [Webhooks]
      x-status: live
      x-rate-limit: "5/minute"
      description: |
        Rotate atomiquement le `signing_secret` HMAC-SHA256
        du webhook actif. Pattern Stripe : nouveau secret au format
        `whsec_<43 chars base64url>`, visible **une seule fois** dans la
        réponse. L'ancien secret ne signe plus aucun event dès le retour
        200 (le dispatcher relit `webhook.secret` à chaque POST).

        Préféré au legacy `DELETE + POST` : sans indisponibilité entre
        les 2 appels, préserve l'URL, pas de 409 à gérer.

        `404 webhook_not_configured` si aucun webhook actif. Rate-limit
        **5/min** (aligné sur `POST /v1/me/api-keys`).
      responses:
        "200":
          description: Nouveau secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSecretRotated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: |
            Aucun webhook actif à rotate (`webhook_not_configured`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              example:
                type: https://docs.yasmine.akidly.com/errors/webhook_not_configured
                title: Not Found
                status: 404
                detail: Aucun webhook actif a rotate. Configurer d'abord via POST /v1/me/webhooks.
                instance: /v1/me/webhooks/rotate-secret
                request_id: a1b2c3d4
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # -------------------------------------------------------------------------
  # Ops — probe santé (hors SLA /v1/*)
  # -------------------------------------------------------------------------
  /v1/shops:
    post:
      operationId: createShop
      summary: Déclarer une boutique
      tags: [Shops]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Enregistre une boutique sur votre compte. **Minimum : `external_id`,
        `name`, `country`** — tout le reste s'ajoute ensuite par `PATCH`.

        Déclarer la boutique ici vous dispense de retransmettre son profil
        (`shop_info`) à chaque demande d'appel.

        **Création stricte** : un `external_id` déjà utilisé renvoie un 409,
        jamais une mise à jour implicite. Pour modifier, utilisez `PATCH`.

        **Pas d'`Idempotency-Key` requise**, contrairement à `POST /v1/calls` :
        l'unicité de l'`external_id` rend déjà la création rejouable sans
        risque de doublon. Un rejeu après timeout réseau renvoie un 409, qui
        confirme que la création initiale a abouti.

        **Téléphones** : `phone` et `whatsapp` sont validés contre le `country`
        déclaré. Format local (`0522123456`) ou international
        (`+212522123456`) au choix — les deux sont acceptés tant que le numéro
        appartient bien à ce pays. Ils sont renvoyés normalisés en E.164.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShopCreate" }
            examples:
              minimal:
                summary: Le strict nécessaire
                value:
                  external_id: "shop-4471"
                  name: "GlowArt Casa"
                  country: "MA"
              complet:
                summary: Fiche renseignée
                value:
                  external_id: "shop-4471"
                  name: "GlowArt Casa"
                  country: "MA"
                  merchant_external_id: "M-4471"
                  phone: "0522123456"
                  whatsapp: "0661234567"
                  email: "contact@glowart.ma"
                  address: "12 Rue des Fleurs, Casablanca"
                  customer_service_hours: "Lun-Sam 9h-18h"
                  sector: "Cosmétique bio artisanale"
                  has_physical_store: true
                  delivery_policy: "Livraison 30 MAD, offerte dès 500 MAD"
                  delivery_delay: "24-48h"
                  payment_policy: "Paiement à la livraison ou carte bancaire"
                  return_policy: "Retour accepté sous 14 jours"
                  exchange_policy: "Échange sous 30 jours, produit non ouvert"
                  inspection_before_payment: true
                  delivery_companies: ["Amana", "CTM"]
                  city_coverage: ["Casablanca", "Rabat", "Marrakech"]
      responses:
        "201":
          description: Boutique créée.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: |
            `external_id` déjà utilisé sur ce compte
            (`shop_external_id_already_exists`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Payload invalide (`validation_error`) : champ manquant, trop
            long, chaîne vide, champ inconnu, ou numéro de téléphone qui
            n'appartient pas au pays déclaré.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    get:
      operationId: listShops
      summary: Lister les boutiques
      tags: [Shops]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Liste paginée des boutiques du compte, triée de la plus récente à
        la plus ancienne.

        **Pagination** : curseur opaque, valable 24 h. Passez le
        `next_cursor` de la réponse en `?cursor=` pour la page suivante.
        Défaut 50 résultats, maximum 200.

        **`?merchant_external_id=`** ne garde que les boutiques d'un même
        commerçant — c'est ainsi que l'on sait combien de boutiques il
        possède.

        **`?status=`** vaut `active` par défaut : les boutiques archivées
        sont masquées. `archived` ne montre qu'elles, `all` ne filtre pas.
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
          description: Curseur de la page suivante, tel que renvoyé par `next_cursor`.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, archived, all]
            default: active
        - in: query
          name: merchant_external_id
          schema: { type: string, maxLength: 128 }
          description: Ne renvoie que les boutiques de ce commerçant.
      responses:
        "200":
          description: Page de résultats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedShops" }
        "400":
          description: |
            Curseur malformé, altéré ou expiré (`invalid_cursor`,
            `cursor_expired`). Refaire la première requête sans `cursor`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/shops/{shop_external_id}:
    get:
      operationId: getShop
      summary: Consulter une boutique
      tags: [Shops]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Renvoie la fiche complète d'une boutique, adressée par **votre**
        identifiant.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
      responses:
        "200":
          description: Fiche de la boutique.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    patch:
      operationId: updateShop
      summary: Modifier une boutique
      tags: [Shops]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Modification partielle. **Trois cas à distinguer :**

        | Dans le corps | Effet |
        |---|---|
        | Champ **absent** | Inchangé |
        | Champ à **`null`** | **Effacé** |
        | Champ à **`""`** | Rejeté (422) |

        La chaîne vide est refusée volontairement : sans cela, « effacer »
        et « mettre une valeur vide » seraient indistinguables.

        ⚠️ Cette sémantique **diffère de `shop_info`** sur `POST /v1/calls`,
        où un champ absent ou `null` n'efface jamais rien. C'est délibéré :
        une demande d'appel est du passage, elle ne détruit rien ; un
        `PATCH` est un geste explicite. Si vous gérez vos boutiques par cet
        endpoint, cessez d'envoyer `shop_info` dans vos demandes d'appel.

        **`external_id` ne peut pas être modifié** — il porte l'historique
        des appels de la boutique. L'envoyer produit un 422.

        **Téléphones** : validés contre le pays *effectif* — celui du corps
        s'il est fourni, sinon celui déjà enregistré. Changer le pays d'une
        boutique revalide donc ses numéros.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShopPatch" }
            examples:
              partielle:
                summary: Deux champs modifiés, un effacé
                value:
                  delivery_delay: "24-48h"
                  payment_policy: "Paiement à la livraison uniquement"
                  current_promotions: null
      responses:
        "200":
          description: Fiche mise à jour.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShopOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "422":
          description: |
            Payload invalide (`validation_error`) : chaîne vide, champ
            inconnu, `external_id` fourni, ou numéro de téléphone qui
            n'appartient pas au pays de la boutique.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    delete:
      operationId: archiveShop
      summary: Archiver une boutique
      tags: [Shops]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        **La boutique n'est pas supprimée, elle est archivée.** Elle
        disparaît de la liste par défaut et n'accepte plus de modification,
        mais tout son historique d'appels reste intact et consultable.

        Pour la retrouver : `GET /v1/shops?status=archived`.

        Archiver une boutique déjà archivée renvoie un 404 —
        indistinguable d'une boutique inexistante.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
      responses:
        "204":
          description: Boutique archivée.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/shops/{shop_external_id}/products:
    post:
      operationId: createProduct
      summary: Déclarer un produit
      tags: [Products]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Ajoute un produit au catalogue d'une boutique. **Minimum :
        `external_id` et `name`** — tout le reste s'ajoute par `PATCH`.

        Déclarer un produit ici vous dispense de le décrire à chaque
        commande : une ligne qui porte `product_external_id` suffit, et
        `POST /v1/calls` relit le nom, la description, le prix et les
        déclinaisons depuis ce catalogue.

        **Deux niveaux, comme partout dans le commerce en ligne** : le
        produit porte l'identité, la variante porte le prix. Un produit sans
        choix n'y échappe pas — si vous n'envoyez pas de `variants`, une
        variante unique est créée pour lui, sans identifiant, et `unit_price`
        s'y dépose. Vous n'avez rien à savoir de tout cela : envoyez un prix,
        relisez un prix.

        **Le prix est obligatoire.** `unit_price` pour un produit sans choix,
        ou celui de chaque entrée de `variants` : un produit sans prix est
        refusé (422), parce qu'un agent qui ne sait pas répondre à « ça coûte
        combien ? » ne confirme rien.

        **Dès qu'il y a plusieurs variantes, chacune doit porter son
        `external_id`** — sans quoi une commande ne pourrait pas désigner
        laquelle. Le refus est un 422 qui nomme les entrées fautives.

        **Création stricte** : un `external_id` déjà utilisé dans cette
        boutique renvoie un 409, jamais une mise à jour implicite. Le même
        identifiant reste libre dans une autre boutique.

        **Pas d'`Idempotency-Key` requise** : l'unicité de l'`external_id`
        rend déjà la création rejouable sans risque de doublon.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreate" }
            examples:
              sans_choix:
                summary: Produit sans déclinaison — un prix, rien d'autre
                value:
                  external_id: "TSHIRT-BLANC"
                  name: "T-shirt blanc en coton"
                  unit_price: "149.00"
              avec_variantes:
                summary: Trois tailles, deux prix
                value:
                  external_id: "TSHIRT-BLANC"
                  name: "T-shirt blanc en coton"
                  description: "Coton bio 180g, coupe droite"
                  product_url: "https://boutique.example/p/tshirt-blanc"
                  variants:
                    - external_id: "TSH-S"
                      options: { Taille: "S" }
                      unit_price: "149.00"
                    - external_id: "TSH-M"
                      options: { Taille: "M" }
                      unit_price: "149.00"
                    - external_id: "TSH-XL"
                      options: { Taille: "XL" }
                      unit_price: "169.00"
                      compare_at_price: "199.00"
              catalogue_riche:
                summary: Fiche complète, avec ce qui prime sur la boutique
                value:
                  external_id: "SERUM-VITC"
                  name: "Sérum vitamine C"
                  description: "Flacon 30 ml, concentration 15%. Ne pas utiliser sur peau lésée, conserver à l'abri de la lumière."
                  max_discount: "20.00"
                  return_policy: "Produit d'hygiène : aucun retour une fois ouvert"
                  current_promotions: "Deuxième flacon à -50%"
                  unit_price: "320.00"
      responses:
        "201":
          description: Produit créé.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "409":
          description: |
            `external_id` déjà utilisé dans cette boutique
            (`product_external_id_already_exists`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Payload invalide (`validation_error`) : champ manquant, trop
            long, chaîne vide, champ inconnu, `external_id` de variante
            manquant ou en double, `compare_at_price` qui n'est pas
            supérieur au prix, ou plus de trois axes d'options.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    get:
      operationId: listProducts
      summary: Lister les produits d'une boutique
      tags: [Products]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Liste paginée du catalogue d'une boutique, du produit le plus
        récent au plus ancien. Chaque fiche est renvoyée complète,
        variantes comprises.

        **Pagination** : curseur opaque, valable 24 h. Passez le
        `next_cursor` de la réponse en `?cursor=` pour la page suivante.
        Défaut 50 résultats, maximum 200.

        **`?status=`** vaut `active` par défaut : les produits archivés sont
        masqués. `archived` ne montre qu'eux, `all` ne filtre pas.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - in: query
          name: cursor
          schema: { type: string }
          description: Curseur de la page suivante, tel que renvoyé par `next_cursor`.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, archived, all]
            default: active
      responses:
        "200":
          description: Page de résultats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedProducts" }
        "400":
          description: |
            Curseur malformé, altéré ou expiré (`invalid_cursor`,
            `cursor_expired`). Refaire la première requête sans `cursor`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/shops/{shop_external_id}/products/{product_external_id}:
    get:
      operationId: getProduct
      summary: Consulter un produit
      tags: [Products]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Renvoie la fiche complète d'un produit, variantes comprises,
        adressée par **vos** identifiants.

        `unit_price` n'est présent que si le produit n'a qu'une variante ;
        sinon, les prix vivent dans `variants`, un par déclinaison.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/ProductExternalId"
      responses:
        "200":
          description: Fiche du produit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ProductNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    patch:
      operationId: updateProduct
      summary: Modifier un produit
      tags: [Products]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Modification partielle. **Trois cas à distinguer :**

        | Dans le corps | Effet |
        |---|---|
        | Champ **absent** | Inchangé |
        | Champ à **`null`** | **Effacé** |
        | Champ à **`""`** | Rejeté (422) |

        ⚠️ **`variants` ne se modifie pas ligne à ligne : la liste envoyée
        remplace entièrement l'ancienne.** Une variante absente de la
        nouvelle liste est supprimée du catalogue. Pour n'en changer qu'une,
        relisez le produit, modifiez l'entrée, renvoyez la liste complète.
        Les commandes déjà passées ne bougent pas — elles ont retenu ce qui
        leur a été dit au moment de l'appel.

        Ne pas envoyer `variants` du tout les laisse intactes : renommer un
        produit n'efface pas ses déclinaisons.

        **`unit_price` seul** ne vaut que pour un produit à variante unique.
        Sur un produit qui en a plusieurs, il est ambigu et refusé (422) :
        c'est dans `variants` que le prix se pose. **`unit_price: null` est
        refusé** dans tous les cas : un prix s'édite, il ne s'efface pas.

        **`external_id` ne peut pas être modifié** — il porte l'historique
        des commandes du produit. L'envoyer produit un 422.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/ProductExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductPatch" }
            examples:
              fiche:
                summary: Deux champs modifiés, un effacé
                value:
                  description: "Coton bio 180g, coupe droite, col rond"
                  max_discount: "15.00"
                  current_promotions: null
              prix_simple:
                summary: Nouveau prix d'un produit sans déclinaison
                value:
                  unit_price: "159.00"
              variantes:
                summary: La liste complète, une taille en moins
                value:
                  variants:
                    - external_id: "TSH-M"
                      options: { Taille: "M" }
                      unit_price: "149.00"
                    - external_id: "TSH-XL"
                      options: { Taille: "XL" }
                      unit_price: "169.00"
      responses:
        "200":
          description: Fiche mise à jour.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ProductNotFound" }
        "422":
          description: |
            Payload invalide (`validation_error`) : chaîne vide, champ
            inconnu, `external_id` fourni, `unit_price` sur un produit à
            plusieurs variantes, ou liste de variantes incohérente.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    delete:
      operationId: archiveProduct
      summary: Archiver un produit
      tags: [Products]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        **Le produit n'est pas supprimé, il est archivé.** Il disparaît du
        catalogue par défaut et ne peut plus être commandé, mais les appels
        qui l'ont mentionné restent intacts et consultables.

        Pour le retrouver : `GET /v1/shops/{shop_external_id}/products?status=archived`.

        Une commande qui le référence après coup est refusée
        (`order_item_unknown_product`) : mieux vaut un appel qui ne part pas
        qu'un appel qui annonce au client un article retiré de la vente.

        Archiver un produit déjà archivé renvoie un 404 — indistinguable
        d'un produit inexistant.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/ProductExternalId"
      responses:
        "204":
          description: Produit archivé.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ProductNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /healthz:
    get:
      operationId: healthz
      summary: Probe santé applicatif
      tags: [Internal]
      x-status: internal
      security: []
      description: |
        Probe santé pour monitoring / load balancer. **Route interne** —
        pas sous le SLA `/v1/*`, pas de rate-limit, pas d'auth, pas de
        versioning. **Ne pas utiliser pour orchestration billing** ni
        comme signal de disponibilité métier — elle renvoie `200 OK` dès
        que le process Python répond, indépendamment de l'état de la DB
        ou des dépendances externes. Pour connaître l'état métier, se
        reporter à `GET /v1/me` (auth requise).
      responses:
        "200":
          description: Process vivant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /v1/shops/{shop_external_id}/orders:
    post:
      operationId: createOrder
      summary: Déclarer une commande
      tags: [Orders]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Déclare une commande sous une boutique, avec **votre** identifiant.
        Minimum : `external_id`, `customer`, `amount`, et de quoi décrire le
        contenu — `items` ou `items_text`.

        Une commande déclarée s'appelle ensuite **par référence** :
        `POST /v1/calls` avec `order_external_id`, et rien d'autre que les
        paramètres de l'appel. Le serveur relit le client, les articles et
        les montants. Relancer le client se fait avec la même référence :
        les appels s'empilent sur la commande, jamais de doublon.

        **Les articles sont figés ici.** Une ligne qui référence un produit
        du catalogue (`product_external_id`, plus `variant_external_id` s'il
        a plusieurs variantes) est résolue à la déclaration — nom, prix,
        variantes, identifiants — et c'est cette copie que chaque appel
        lira, même si le catalogue change ensuite. Une ligne décrite sur
        place avec `name` reste valide : article sur mesure, frais de port,
        cadeau.

        **Le statut ne s'écrit pas.** `order_status` vient du résultat des
        appels : `PENDING`, puis `CONFIRMED`, `CANCELLED`, `UNREACHABLE`…
        Il se lit.

        **Création stricte** : un `external_id` déjà pris dans cette
        boutique renvoie un 409, jamais une mise à jour implicite.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderDeclare" }
            examples:
              par_reference:
                summary: Deux articles du catalogue, un client, un montant
                value:
                  external_id: "CMD-2026-8891"
                  customer:
                    name: "Ahmed Bennani"
                    phone_number: "+212612345678"
                  amount: "487.00"
                  delivery_address: "12 rue des Orangers, Casablanca"
                  items:
                    - product_external_id: "TSHIRT-BLANC"
                      variant_external_id: "TSH-XL"
                      quantity: 1
                    - product_external_id: "SERUM-VITC"
                      quantity: 1
              avec_frais_de_port:
                summary: Un article du catalogue et une ligne décrite sur place
                value:
                  external_id: "CMD-2026-8892"
                  customer:
                    name: "Salma Idrissi"
                    phone_number: "+212661234567"
                    gender: "female"
                  amount: "179.00"
                  shipping_cost: "30.00"
                  estimated_delivery_date: "2026-09-05"
                  order_notes: "Cadeau : ne pas mentionner le prix"
                  items:
                    - product_external_id: "TSHIRT-BLANC"
                      variant_external_id: "TSH-M"
                      quantity: 1
                    - name: "Frais de livraison"
                      quantity: 1
                      unit_price: "30.00"
      responses:
        "201":
          description: Commande déclarée, articles figés.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "409":
          description: |
            `external_id` déjà utilisé dans cette boutique
            (`order_external_id_already_exists`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Payload invalide (`validation_error`) : champ manquant, trop
            long, chaîne vide, champ inconnu, ni `items` ni `items_text`.
            Ou une ligne qui référence un produit inconnu
            (`order_item_unknown_product`) ou une variante non précisée
            (`order_item_variant_required`, le `detail` liste celles qui
            existent).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    get:
      operationId: listOrders
      summary: Lister les commandes d'une boutique
      tags: [Orders]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Liste paginée des commandes d'une boutique, de la plus récente à
        la plus ancienne. Chaque fiche est renvoyée complète, appels
        compris.

        **Pagination** : curseur opaque, valable 24 h. Passez le
        `next_cursor` de la réponse en `?cursor=` pour la page suivante.
        Défaut 50 résultats, maximum 200.

        **`?status=`** vaut `active` par défaut : les commandes archivées
        sont masquées. `archived` ne montre qu'elles, `all` ne filtre pas.
        **`?order_status=`** ne garde que les commandes dans cet état
        (`PENDING`, `CONFIRMED`…).
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - in: query
          name: cursor
          schema: { type: string }
          description: Curseur de la page suivante, tel que renvoyé par `next_cursor`.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, archived, all]
            default: active
        - in: query
          name: order_status
          schema: { type: string, maxLength: 32 }
          description: Ne garder que les commandes dans cet état (`PENDING`, `CONFIRMED`…).
      responses:
        "200":
          description: Page de résultats.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedOrders" }
        "400":
          description: |
            Curseur malformé, altéré ou expiré (`invalid_cursor`,
            `cursor_expired`). Refaire la première requête sans `cursor`.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/ShopNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/shops/{shop_external_id}/orders/{order_external_id}:
    get:
      operationId: getOrder
      summary: Consulter une commande
      tags: [Orders]
      x-status: live
      x-rate-limit: "600/minute"
      description: |
        Renvoie la fiche complète d'une commande, adressée par **vos**
        identifiants : client, articles figés, montants, `order_status`,
        et la liste de ses appels avec leur résultat. `call_in_progress`
        dit si un appel n'est pas terminé — la commande ne peut alors ni
        être modifiée, ni archivée, ni rappelée.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/OrderExternalId"
      responses:
        "200":
          description: Fiche de la commande.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    patch:
      operationId: updateOrder
      summary: Modifier une commande
      tags: [Orders]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Modification partielle. **Trois cas à distinguer :**

        | Dans le corps | Effet |
        |---|---|
        | Champ **absent** | Inchangé |
        | Champ à **`null`** | **Effacé** |
        | Champ à **`""`** | Rejeté (422) |

        Trois champs ne s'effacent pas — `customer`, `amount`, `items` :
        une commande a toujours un client, un montant et un contenu.
        Les envoyer à `null` produit un 422.

        ⚠️ **`items` remplace entièrement la liste**, et chaque ligne est
        résolue à nouveau depuis le catalogue : la copie figée est refaite
        avec les prix du jour. Ne pas envoyer `items` la laisse intacte.

        **Refusé pendant un appel en cours** (`409 order_call_in_progress`) :
        l'agent est en ligne avec ce qu'il a lu. Attendre la fin de l'appel.

        **`external_id` ne peut pas être modifié** — il porte l'historique
        des appels. L'envoyer avec une autre valeur produit un 422.
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/OrderExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderPatch" }
            examples:
              adresse_et_montant:
                summary: L'adresse corrigée, une remise consentie
                value:
                  delivery_address: "14 rue des Orangers, Casablanca"
                  amount: "450.00"
                  discount: "37.00"
              articles:
                summary: La liste complète, un article en moins
                value:
                  items:
                    - product_external_id: "SERUM-VITC"
                      quantity: 1
                  amount: "320.00"
      responses:
        "200":
          description: Fiche mise à jour.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderOut" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "409":
          description: |
            Un appel est en cours sur cette commande
            (`order_call_in_progress`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: |
            Payload invalide (`validation_error`) : chaîne vide, champ
            inconnu, `external_id` différent de l'URL, `customer`,
            `amount` ou `items` à `null` ; ou une ligne qui référence un
            produit inconnu (`order_item_unknown_product`) ou une variante
            non précisée (`order_item_variant_required`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    delete:
      operationId: archiveOrder
      summary: Archiver une commande
      tags: [Orders]
      x-status: live
      x-rate-limit: "120/minute"
      description: |
        Archive la commande — **elle n'est pas supprimée**. Elle disparaît
        de la liste par défaut et ne peut plus être appelée ; ses appels et
        son historique restent lisibles (`?status=archived`).

        **Refusé pendant un appel en cours** (`409 order_call_in_progress`).
      parameters:
        - $ref: "#/components/parameters/ShopExternalId"
        - $ref: "#/components/parameters/OrderExternalId"
      responses:
        "204":
          description: Commande archivée. Aucun corps.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "409":
          description: |
            Un appel est en cours sur cette commande
            (`order_call_in_progress`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: yk_... (38 chars)
      description: |
        Clé API au format `yk_...`. Fournie manuellement par l'équipe
        Yasmine (bootstrap). Self-service `POST /v1/me/api-keys` à venir
        en M4.
  parameters:
    ShopExternalId:
      in: path
      name: shop_external_id
      required: true
      schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" }
      description: |
        Identifiant de la boutique **dans votre système**, celui fourni à
        la création. Jamais notre UUID interne.
      example: "shop-4471"
    ProductExternalId:
      in: path
      name: product_external_id
      required: true
      schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" }
      description: |
        Identifiant du produit **dans votre systeme**, celui fourni a la
        creation. Jamais notre UUID interne.
      example: "TSHIRT-01"
    OrderExternalId:
      in: path
      name: order_external_id
      required: true
      schema: { type: string, maxLength: 128, pattern: "^[a-zA-Z0-9_.-]+$" }
      description: |
        Identifiant de la commande **dans votre systeme**, celui fourni a la
        declaration. Jamais notre UUID interne.
      example: "CMD-2026-8891"
    RequestIdHeader:
      in: header
      name: X-Request-ID
      required: false
      schema:
        type: string
        format: uuid
      description: |
        UUID v4 que vous souhaitez attribuer à cette requête.

        - Si présent et format UUID v4 strict valide (RFC 4122) : Yasmine
          le respecte et le retourne tel quel en header response.
        - Si absent, vide, ou format invalide : Yasmine en génère un
          nouveau (rejet sécurisé contre la log injection / path traversal).

        Toutes les responses incluent ce header. Les erreurs RFC 7807
        incluent le même `request_id` dans le body. Joignez-le à tout
        ticket support pour tracer une requête côté serveur.
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 255
      description: |
        Clé d'idempotence **obligatoire**. Format libre, 1-255
        chars (UUID v4 recommandé). Scope par reseller, TTL 24 h.

        - **Replay** (même clé + même body) → la réponse stockée est
          rejouée bit-for-bit avec `X-Idempotent-Replay: true`. Aucun
          nouvel appel, aucun nouveau débit.
        - **Conflit** (même clé + body différent) → `409
          idempotency_key_conflict`. Générer une nouvelle clé pour une
          requête différente.
        - **Header absent / vide / > 255 chars** → `400` avec slug
          `missing_idempotency_key` / `idempotency_key_empty` /
          `idempotency_key_too_long`.
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      in: query
      name: cursor
      schema: { type: string }
      description: Opaque. Fourni par `next_cursor` de la page précédente.
    Sort:
      in: query
      name: sort
      schema: { type: string, default: "-created_at" }
      description: Champ + signe. `-` pour DESC. Défaut `-created_at`.
    CallId:
      in: path
      name: call_id
      required: true
      # On accepte n'importe quelle string en path. Un UUID
      # syntaxiquement invalide est traite cote serveur comme 404
      # `call_not_found` (pas 422), pour homogeneiser l'anti-enum.
      schema: { type: string }
  headers:
    RequestIdResponse:
      description: |
        UUID v4 qui identifie cette requête côté serveur. Émis
        sur **toutes** les responses, succès comme erreur. Respecte la
        valeur fournie par le client via le header `X-Request-ID` si
        format UUID v4 strict valide, sinon régénère. À joindre à tout
        ticket support pour tracer une requête.
      schema:
        type: string
        format: uuid
      example: 3f8e9d12-4b5a-4c6d-8e7f-123456789abc
    RateLimitLimit:
      description: |
        Plafond rate-limit appliqué à cet endpoint pour la clé API
        courante, exprimé en requêtes par minute. Identique
        quelle que soit l'issue de la requête (2xx, 4xx, 5xx).
      schema: { type: integer }
      example: 600
    RateLimitRemaining:
      description: |
        Nombre de requêtes restant dans la fenêtre courante.
        Vaut `0` sur une 429.
      schema: { type: integer }
      example: 597
    RateLimitReset:
      description: |
        Timestamp Unix (secondes depuis l'epoch) du prochain reset de
        la fenêtre rate-limit.
      schema: { type: integer }
      example: 1714744860
  responses:
    ShopNotFound:
      description: |
        Aucune boutique active avec cet identifiant sur votre compte
        (`shop_not_found`). **Corps identique dans trois cas** — elle
        n'existe pas, elle est archivée, ou elle appartient à un autre
        compte. Une réponse différenciée permettrait de deviner quelles
        boutiques existent ailleurs.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ProductNotFound:
      description: |
        Rien a cette adresse. **Deux slugs possibles**, et le corps ne dit
        rien de plus :

        - `shop_not_found` — la boutique du chemin est introuvable ;
        - `product_not_found` — la boutique existe, le produit non.

        Chaque slug couvre **trois situations au corps identique** : cela
        n'existe pas, c'est archive, ou cela appartient a un autre compte.
        Une reponse differenciee permettrait de deviner ce qui existe
        ailleurs.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    OrderNotFound:
      description: |
        Rien a cette adresse. **Deux slugs possibles**, et le corps ne dit
        rien de plus :

        - `shop_not_found` — la boutique du chemin est introuvable ;
        - `order_not_found` — la boutique existe, la commande non.

        Chaque slug couvre **trois situations au corps identique** : cela
        n'existe pas, c'est archive, ou cela appartient a un autre compte.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Clé API absente, invalide ou révoquée.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    CallNotFound:
      description: |
        Appel introuvable. **Body byte-identique** sur les 3 cas
        possibles (UUID invalide / inconnu / autre tenant) — anti-énum.
        Slug stable `call_not_found`.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://docs.yasmine.akidly.com/errors/call_not_found
            title: Not Found
            status: 404
            detail: Aucun appel trouve pour cet identifiant.
            instance: /v1/calls/00000000-0000-0000-0000-000000000000
            request_id: a1b2c3d4
    NotFound:
      description: Ressource introuvable ou non accessible au reseller courant.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationError:
      description: Payload mal formé — détails dans `errors[]`.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Problem"
              - type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        loc: { type: array, items: { type: string } }
                        msg: { type: string }
                        type: { type: string }
    IdempotencyKeyError:
      description: |
        Header `Idempotency-Key` invalide. Trois slugs possibles :
        `missing_idempotency_key` (absent), `idempotency_key_empty`
        (chaîne vide), `idempotency_key_too_long` (> 255 chars).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            missing:
              summary: Header absent
              value:
                type: https://docs.yasmine.akidly.com/errors/missing_idempotency_key
                title: Bad Request
                status: 400
                detail: Le header Idempotency-Key est obligatoire sur POST /v1/calls.
                instance: /v1/calls
                request_id: a1b2c3d4
            empty:
              summary: Header vide
              value:
                type: https://docs.yasmine.akidly.com/errors/idempotency_key_empty
                title: Bad Request
                status: 400
                detail: Idempotency-Key ne peut pas etre vide.
                instance: /v1/calls
                request_id: a1b2c3d4
            too_long:
              summary: Header > 255 chars
              value:
                type: https://docs.yasmine.akidly.com/errors/idempotency_key_too_long
                title: Bad Request
                status: 400
                detail: "Idempotency-Key ne peut pas depasser 255 caracteres (recu : 312)."
                instance: /v1/calls
                request_id: a1b2c3d4
    IdempotencyKeyConflict:
      description: |
        Idempotency-Key déjà utilisée avec un body différent. Générer
        une nouvelle clé pour une requête différente.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://docs.yasmine.akidly.com/errors/idempotency_key_conflict
            title: Conflict
            status: 409
            detail: Idempotency-Key 'order-cmd-2026-789' a deja ete utilisee avec un body different.
            instance: /v1/calls
            request_id: a1b2c3d4
    InsufficientBalance:
      description: Solde insuffisant pour déclencher l'appel.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Problem"
              - type: object
                properties:
                  balance_seconds: { type: integer }
                  required_seconds: { type: integer }
    RateLimited:
      description: |
        Rate-limit par clé API dépassé. Slug
        `rate_limit_exceeded`. Respecter `Retry-After` (secondes) avant
        de retenter.
      headers:
        Retry-After:
          description: Secondes avant le prochain reset du bucket.
          schema: { type: integer }
        X-RateLimit-Limit:
          description: Plafond appliqué à cet endpoint (requêtes par minute).
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Compteur restant dans la fenêtre courante. Vaut `0` sur 429.
          schema: { type: integer }
        X-RateLimit-Reset:
          description: Timestamp Unix du prochain reset.
          schema: { type: integer }
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Problem"
              - type: object
                properties:
                  retry_after:
                    type: integer
                    description: Secondes avant le prochain reset (identique au header).
    InternalError:
      description: |
        Erreur interne inattendue côté Yasmine (slug `internal_error`).
        Toujours loguée côté serveur avec le `request_id` correspondant.
        Le client doit retenter (idempotent), ou contacter le support en
        joignant `request_id`. Le détail de la stack n'est jamais exposé.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://docs.yasmine.akidly.com/errors/internal_error
            title: Internal Server Error
            status: 500
            detail: Une erreur interne est survenue. Contacter le support avec la valeur de request_id.
            instance: /v1/calls
            request_id: a1b2c3d4
  schemas:
    # --- Boutiques -------------------------------------------------------
    # ⚠️ Ces schemas sont GENERES depuis le contrat livre. Ne pas les
    # editer a la main : la spec et le service divergeraient, exactement
    # le defaut trouve le 2026-09-02 sur `inspection_before_payment`,
    # annonce par la documentation mais refuse en 422. Un controle
    # automatique compare desormais les deux et echoue en cas d'ecart.
    ShopCreate:
      additionalProperties: false
      description: 'Payload `POST /v1/shops`. Minimum : `external_id`, `name`, `country`.'
      properties:
        name:
          maxLength: 200
          minLength: 1
          type: string
        country:
          enum:
          - MA
          - DZ
          - TN
          type: string
        default_language:
          enum:
          - ar
          - fr
          type: string
          nullable: true
          description: 'Registre habituel : `ar` (darija locale) ou `fr`. Sert de defaut quand
            une demande d''appel ne precise pas la langue. Ce n''est PAS un choix de dialecte
            : celui-ci se deduit du pays du client appele.'
        merchant_external_id:
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          nullable: true
          description: Identifiant du commercant proprietaire, tel qu'il existe chez vous. Regroupe
            plusieurs boutiques sous un meme commercant. A ne pas confondre avec `external_id`,
            qui identifie LA boutique.
          example: M-4471
        phone:
          maxLength: 30
          minLength: 1
          type: string
          nullable: true
          description: Telephone de la boutique. Format local ou international accepte, mais le
            numero doit appartenir au pays declare. Renvoye normalise en E.164.
          example: '0522123456'
        whatsapp:
          maxLength: 30
          minLength: 1
          type: string
          nullable: true
          description: Numero WhatsApp, memes regles que `phone`.
        email:
          maxLength: 50
          minLength: 1
          pattern: ^[^@\s]+@[^@\s]+\.[^@\s]+$
          type: string
          nullable: true
          description: Email de contact. Normalise en minuscules.
        address:
          maxLength: 200
          minLength: 1
          type: string
          nullable: true
        customer_service_hours:
          maxLength: 100
          minLength: 1
          type: string
          nullable: true
          description: Horaires du service client, texte libre.
          example: Lun-Sam 9h-18h
        website_url:
          maxLength: 255
          minLength: 1
          pattern: ^https?://[^\s]+$
          type: string
          nullable: true
        sector:
          maxLength: 80
          minLength: 1
          type: string
          nullable: true
          description: Secteur d'activite, texte libre. « Boutique de cosmetique bio artisanale
            » informe mieux qu'un code ferme.
        has_physical_store:
          type: boolean
          nullable: true
          description: 'Point de vente ou le client peut se presenter ? `true` oui, `false` vente
            a distance uniquement. Absent ou `null` vaut `false` : le troisieme etat « non declare
            » a ete retire le 2026-09-03. Repond a « puis-je venir ? », question que `address`
            ne tranche pas.'
        delivery_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        delivery_delay:
          maxLength: 100
          minLength: 1
          type: string
          nullable: true
        payment_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        return_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        exchange_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        warranty_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        inspection_before_payment:
          type: boolean
          nullable: true
          description: Le client peut-il ouvrir le colis avant de payer ? Absent ou `null` vaut
            `false` — le troisieme etat « non declare » a ete retire le 2026-09-03.
        faq_free:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
        current_promotions:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Remise maximale que l''agent pourra accorder. Interpretee dans la devise
            du pays de la boutique (MA -> MAD, DZ -> DZD, TN -> TND). Plafond ramene de 1 000
            000 a 10 000 le 2026-09-03 : au-dela, ce n''est plus une remise negociable au telephone.
            La remise d''UNE commande garde son propre plafond, plus haut.'
          nullable: true
        delivery_companies:
          items:
            maxLength: 60
            minLength: 1
            type: string
          maxItems: 10
          type: array
          nullable: true
          description: Transporteurs avec lesquels la boutique travaille.
        city_coverage:
          items:
            maxLength: 80
            minLength: 1
            type: string
          maxItems: 200
          type: array
          nullable: true
          description: 'Villes livrees. **Present = liste fermee** : l''agent annonce la non-livraison
            ailleurs. Absent = aucune restriction.'
        tone:
          enum:
          - formal
          - friendly
          - default
          type: string
          nullable: true
        agent_name:
          maxLength: 20
          minLength: 1
          pattern: ^[A-Za-zÀ-ÿ؀-ۿ][A-Za-zÀ-ÿ؀-ۿ \.'\-]*$
          type: string
          nullable: true
          description: 'Prenom de l''agent. Defaut : Yasmine.'
        voice_id:
          maxLength: 32
          minLength: 1
          type: string
          nullable: true
        max_call_duration_seconds:
          maximum: 600
          minimum: 30
          type: integer
          nullable: true
        external_id:
          description: Identifiant de la boutique dans VOTRE systeme. Sert d'adresse dans l'URL,
            d'ou le jeu de caracteres restreint. **Immuable** apres creation.
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          example: shop-4471
      required:
      - name
      - country
      - external_id
      type: object
    ShopPatch:
      additionalProperties: false
      description: 'Payload `PATCH /v1/shops/{external_id}` — modification partielle.


        Semantique, differente de l''upsert des demandes d''appel :


        - champ **absent** -> inchange

        - champ a **`null`** -> **efface**

        - champ a **`""`** -> 422


        `external_id` est **accepte mais jamais ecrit** : il identifie la

        boutique, il ne se modifie pas. Un integrateur l''envoie naturellement

        pour dire de quelle boutique il parle — le refuser transformait ce

        reflexe en 422 opaque (defaut vecu le 2026-09-03). S''il est fourni et

        qu''il differe de celui de l''URL, la requete est refusee : deux

        identifiants qui se contredisent ne se tranchent pas en silence.'
      properties:
        name:
          maxLength: 200
          minLength: 1
          type: string
          nullable: true
          description: Nom commercial, prononce par l'agent au telephone.
          example: GlowArt Casa
        country:
          enum:
          - MA
          - DZ
          - TN
          type: string
          nullable: true
          description: Pays d'etablissement de la boutique. Determine le format attendu de `phone`
            et `whatsapp`, le fuseau horaire, et la devise dans laquelle `max_discount` est interprete.
          example: MA
        default_language:
          enum:
          - ar
          - fr
          type: string
          nullable: true
          description: 'Registre habituel : `ar` (darija locale) ou `fr`. Sert de defaut quand
            une demande d''appel ne precise pas la langue. Ce n''est PAS un choix de dialecte
            : celui-ci se deduit du pays du client appele.'
        merchant_external_id:
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          nullable: true
          description: Identifiant du commercant proprietaire, tel qu'il existe chez vous. Regroupe
            plusieurs boutiques sous un meme commercant. A ne pas confondre avec `external_id`,
            qui identifie LA boutique.
          example: M-4471
        phone:
          maxLength: 30
          minLength: 1
          type: string
          nullable: true
          description: Telephone de la boutique. Format local ou international accepte, mais le
            numero doit appartenir au pays declare. Renvoye normalise en E.164.
          example: '0522123456'
        whatsapp:
          maxLength: 30
          minLength: 1
          type: string
          nullable: true
          description: Numero WhatsApp, memes regles que `phone`.
        email:
          maxLength: 50
          minLength: 1
          pattern: ^[^@\s]+@[^@\s]+\.[^@\s]+$
          type: string
          nullable: true
          description: Email de contact. Normalise en minuscules.
        address:
          maxLength: 200
          minLength: 1
          type: string
          nullable: true
        customer_service_hours:
          maxLength: 100
          minLength: 1
          type: string
          nullable: true
          description: Horaires du service client, texte libre.
          example: Lun-Sam 9h-18h
        website_url:
          maxLength: 255
          minLength: 1
          pattern: ^https?://[^\s]+$
          type: string
          nullable: true
        sector:
          maxLength: 80
          minLength: 1
          type: string
          nullable: true
          description: Secteur d'activite, texte libre. « Boutique de cosmetique bio artisanale
            » informe mieux qu'un code ferme.
        has_physical_store:
          type: boolean
          nullable: true
          description: 'Point de vente ou le client peut se presenter ? `true` oui, `false` vente
            a distance uniquement. Absent ou `null` vaut `false` : le troisieme etat « non declare
            » a ete retire le 2026-09-03. Repond a « puis-je venir ? », question que `address`
            ne tranche pas.'
        delivery_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        delivery_delay:
          maxLength: 100
          minLength: 1
          type: string
          nullable: true
        payment_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        return_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        exchange_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        warranty_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        inspection_before_payment:
          type: boolean
          nullable: true
          description: Le client peut-il ouvrir le colis avant de payer ? Absent ou `null` vaut
            `false` — le troisieme etat « non declare » a ete retire le 2026-09-03.
        faq_free:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
        current_promotions:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Remise maximale que l''agent pourra accorder. Interpretee dans la devise
            du pays de la boutique (MA -> MAD, DZ -> DZD, TN -> TND). Plafond ramene de 1 000
            000 a 10 000 le 2026-09-03 : au-dela, ce n''est plus une remise negociable au telephone.
            La remise d''UNE commande garde son propre plafond, plus haut.'
          nullable: true
        delivery_companies:
          items:
            maxLength: 60
            minLength: 1
            type: string
          maxItems: 10
          type: array
          nullable: true
          description: Transporteurs avec lesquels la boutique travaille.
        city_coverage:
          items:
            maxLength: 80
            minLength: 1
            type: string
          maxItems: 200
          type: array
          nullable: true
          description: 'Villes livrees. **Present = liste fermee** : l''agent annonce la non-livraison
            ailleurs. Absent = aucune restriction.'
        tone:
          enum:
          - formal
          - friendly
          - default
          type: string
          nullable: true
        agent_name:
          maxLength: 20
          minLength: 1
          pattern: ^[A-Za-zÀ-ÿ؀-ۿ][A-Za-zÀ-ÿ؀-ۿ \.'\-]*$
          type: string
          nullable: true
          description: 'Prenom de l''agent. Defaut : Yasmine.'
        voice_id:
          maxLength: 32
          minLength: 1
          type: string
          nullable: true
        max_call_duration_seconds:
          maximum: 600
          minimum: 30
          type: integer
          nullable: true
        external_id:
          type: string
          nullable: true
          description: 'Facultatif, et purement redondant : la boutique est deja designee par
            l''URL. Accepte parce qu''un client l''envoie naturellement comme cle. **Jamais ecrit**
            — l''identifiant est immuable. S''il differe de celui de l''URL, la requete est refusee
            (422 `validation_error`).'
      type: object
    ShopOut:
      additionalProperties: false
      description: Fiche boutique renvoyee par `/v1/shops`.
      properties:
        id:
          description: Identifiant interne Yasmine, a citer au support.
          format: uuid
          type: string
        external_id:
          type: string
        name:
          type: string
        country:
          type: string
          nullable: true
        default_language:
          type: string
          nullable: true
        merchant_external_id:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        whatsapp:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        customer_service_hours:
          type: string
          nullable: true
        website_url:
          type: string
          nullable: true
        sector:
          type: string
          nullable: true
        has_physical_store:
          default: false
          type: boolean
        delivery_policy:
          type: string
          nullable: true
        delivery_delay:
          type: string
          nullable: true
        payment_policy:
          type: string
          nullable: true
        return_policy:
          type: string
          nullable: true
        exchange_policy:
          type: string
          nullable: true
        warranty_policy:
          type: string
          nullable: true
        inspection_before_payment:
          default: false
          type: boolean
        faq_free:
          type: string
          nullable: true
        current_promotions:
          type: string
          nullable: true
        max_discount:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        delivery_companies:
          items:
            type: string
          type: array
          nullable: true
        city_coverage:
          items:
            type: string
          type: array
          nullable: true
        tone:
          type: string
          nullable: true
        agent_name:
          type: string
          nullable: true
        voice_id:
          type: string
          nullable: true
        max_call_duration_seconds:
          type: integer
          nullable: true
        status:
          description: Calcule depuis `archived_at`.
          enum:
          - active
          - archived
          type: string
        created_at:
          format: date-time
          type: string
        updated_at:
          format: date-time
          type: string
        archived_at:
          format: date-time
          type: string
          nullable: true
      required:
      - id
      - external_id
      - name
      - status
      - created_at
      - updated_at
      type: object
    PaginatedShops:
      additionalProperties: false
      description: Enveloppe paginee `GET /v1/shops`.
      properties:
        data:
          items:
            $ref: '#/components/schemas/ShopOut'
          type: array
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
      required:
      - data
      - has_more
      type: object
    ProductVariantInput:
      additionalProperties: false
      description: 'Une variante declaree dans un produit.


        `external_id` est **conditionnellement requis** : facultatif si le

        produit n''a qu''une variante, obligatoire des qu''il en a plusieurs —

        sinon une commande ne pourrait pas designer laquelle. La verification

        porte sur la liste entiere, pas sur une entree isolee.'
      properties:
        external_id:
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          nullable: true
          description: Identifiant de la variante dans VOTRE systeme. Facultatif si le produit
            n'en a qu'une, obligatoire des qu'il en a plusieurs.
          example: TSH-XL
        options:
          additionalProperties:
            type: string
          type: object
          nullable: true
          description: 'Ce qui distingue cette variante : `{"Taille": "XL"}`. Trois axes au maximum.
            Vide ou absent pour un produit sans choix.'
          example:
            Couleur: rouge
            Taille: XL
        unit_price:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          description: 'Prix de CETTE variante, dans la devise du pays de la boutique. **Obligatoire**
            : une variante sans prix ne se vend pas, et l''agent ne saurait pas repondre a « ca
            coute combien ? ». C''est ici que vit le prix — le XL peut couter plus cher que le
            S.'
        compare_at_price:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Prix barre, pour valoriser une remise : « c''etait 300, vous l''avez a
            250 ». **Doit etre superieur a `unit_price`** — l''inverse serait forcement une erreur
            de saisie.'
          nullable: true
        sku:
          maxLength: 50
          minLength: 1
          type: string
          nullable: true
          description: Reference de stock, a votre usage. Conservee telle quelle et renvoyee dans
            les lectures ; jamais prononcee au client.
          example: TSH-BLC-XL
      required:
      - unit_price
      type: object
    ProductCreate:
      additionalProperties: false
      description: 'Payload `POST /v1/shops/{id}/products`.


        Minimum : `external_id`, `name`, et un prix — `unit_price` pour un

        produit sans choix, ou celui de chaque entree de `variants`.'
      properties:
        name:
          description: Nom du produit tel qu'il sera prononce au client. Ecrivez-le comme on le
            dit, pas comme on le reference en base.
          maxLength: 150
          minLength: 1
          type: string
          example: T-shirt blanc en coton
        description:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: Matiere, contenu, usage. Sert a repondre aux questions du client sur l'article
            ; n'est pas recitee d'office.
        product_url:
          maxLength: 300
          minLength: 1
          pattern: ^https?://[^\s]+$
          type: string
          nullable: true
          description: Lien vers la fiche produit sur votre site. Conserve pour vos propres besoins
            et vos exports ; jamais lu au telephone.
          example: https://boutique.example/produits/tshirt-blanc
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Remise maximale sur ce produit. Absent : celle de la boutique s''applique,
            dans la limite de 10 000. Sur une commande a plusieurs produits, c''est **la plus
            haute** qui vaut — et une valeur a `0` ne bloque rien, elle signifie seulement que
            ce produit n''apporte pas de marge.'
          nullable: true
        return_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
          description: 'Politique de retour propre a ce produit — hygiene, sur-mesure. Absent
            : celle de la boutique s''applique.'
        current_promotions:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
          description: 'Promotion sur ce produit. S''ajoute a celle de la boutique, elle ne la
            remplace pas : les deux sont vraies.'
        unit_price:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: '**Le prix d''un produit sans choix.** Il est pose sur la variante unique,
            creee automatiquement. **Obligatoire a la creation** si `variants` est absent : un
            produit sans prix n''existe pas. En lecture, ce champ renvoie ce prix — mais seulement
            si le produit n''a qu''une variante ; sinon il est absent et il faut lire `variants`.
            Jamais `null` sur un `PATCH` : un prix s''edite, il ne s''efface pas.'
          nullable: true
        variants:
          items:
            $ref: '#/components/schemas/ProductVariantInput'
          maxItems: 60
          type: array
          nullable: true
          description: 'Les declinaisons vendables. **Absent : le produit recoit une variante
            unique** portant `unit_price` — vous n''avez rien a declarer pour un produit sans
            choix.


            Sur un `PATCH`, la liste envoyee **remplace entierement** l''ancienne ; une variante
            qui n''y figure plus est supprimee. Les commandes deja passees ne bougent pas.'
        external_id:
          description: Identifiant du produit dans VOTRE systeme. Sert d'adresse dans l'URL, d'ou
            le jeu de caracteres restreint. **Immuable** apres creation.
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          example: TSHIRT-01
      required:
      - name
      - external_id
      type: object
    ProductPatch:
      additionalProperties: false
      description: 'Payload `PATCH` — modification partielle.


        Champ **absent** = inchange, champ a **`null`** = efface, chaine vide =

        422. Meme semantique que sur les boutiques.


        `external_id` est **accepte comme cle de confirmation** : un logiciel

        qui pousse une mise a jour envoie naturellement l''identifiant du

        produit qu''il modifie. Il doit valoir celui de l''URL — sinon 422 —

        et n''est jamais ecrit : un produit deja commande ne se renomme pas.'
      properties:
        name:
          maxLength: 150
          minLength: 1
          type: string
          nullable: true
          description: Nom du produit tel qu'il sera prononce au client. Ecrivez-le comme on le
            dit, pas comme on le reference en base.
          example: T-shirt blanc en coton
        description:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: Matiere, contenu, usage. Sert a repondre aux questions du client sur l'article
            ; n'est pas recitee d'office.
        product_url:
          maxLength: 300
          minLength: 1
          pattern: ^https?://[^\s]+$
          type: string
          nullable: true
          description: Lien vers la fiche produit sur votre site. Conserve pour vos propres besoins
            et vos exports ; jamais lu au telephone.
          example: https://boutique.example/produits/tshirt-blanc
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Remise maximale sur ce produit. Absent : celle de la boutique s''applique,
            dans la limite de 10 000. Sur une commande a plusieurs produits, c''est **la plus
            haute** qui vaut — et une valeur a `0` ne bloque rien, elle signifie seulement que
            ce produit n''apporte pas de marge.'
          nullable: true
        return_policy:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
          description: 'Politique de retour propre a ce produit — hygiene, sur-mesure. Absent
            : celle de la boutique s''applique.'
        current_promotions:
          maxLength: 250
          minLength: 1
          type: string
          nullable: true
          description: 'Promotion sur ce produit. S''ajoute a celle de la boutique, elle ne la
            remplace pas : les deux sont vraies.'
        unit_price:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: '**Le prix d''un produit sans choix.** Il est pose sur la variante unique,
            creee automatiquement. **Obligatoire a la creation** si `variants` est absent : un
            produit sans prix n''existe pas. En lecture, ce champ renvoie ce prix — mais seulement
            si le produit n''a qu''une variante ; sinon il est absent et il faut lire `variants`.
            Jamais `null` sur un `PATCH` : un prix s''edite, il ne s''efface pas.'
          nullable: true
        variants:
          items:
            $ref: '#/components/schemas/ProductVariantInput'
          maxItems: 60
          type: array
          nullable: true
          description: 'Les declinaisons vendables. **Absent : le produit recoit une variante
            unique** portant `unit_price` — vous n''avez rien a declarer pour un produit sans
            choix.


            Sur un `PATCH`, la liste envoyee **remplace entierement** l''ancienne ; une variante
            qui n''y figure plus est supprimee. Les commandes deja passees ne bougent pas.'
        external_id:
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          nullable: true
          description: 'Facultatif, et purement confirmatif : s''il est present il doit valoir
            celui de l''URL. L''identifiant d''un produit ne se modifie pas.'
          example: TSHIRT-01
      type: object
    ProductVariantOut:
      additionalProperties: false
      description: Une variante, telle que renvoyee.
      properties:
        id:
          format: uuid
          type: string
        external_id:
          type: string
          nullable: true
          description: '`null` sur la variante d''un produit sans choix : vous ne l''avez pas
            nommee, et vous n''avez pas a le faire.'
        options:
          additionalProperties:
            type: string
          type: object
        unit_price:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          description: Prix de cette variante. Toujours present.
        compare_at_price:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        sku:
          type: string
          nullable: true
        position:
          description: 'Rang de la variante dans le produit, a partir de 0 — celui de la liste
            que vous avez envoyee. Il rend les lectures stables : deux appels successifs renvoient
            les variantes dans le meme ordre.'
          type: integer
      required:
      - id
      - options
      - unit_price
      - position
      type: object
    ProductOut:
      additionalProperties: false
      description: Fiche produit complete, variantes comprises.
      properties:
        id:
          format: uuid
          type: string
        external_id:
          type: string
        name:
          type: string
        description:
          type: string
          nullable: true
        product_url:
          type: string
          nullable: true
        max_discount:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        return_policy:
          type: string
          nullable: true
        current_promotions:
          type: string
          nullable: true
        unit_price:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: Prix de la variante unique. **Absent si le produit a plusieurs variantes**
            — lire `variants` dans ce cas.
          nullable: true
        variants:
          items:
            $ref: '#/components/schemas/ProductVariantOut'
          type: array
        status:
          enum:
          - active
          - archived
          type: string
        created_at:
          format: date-time
          type: string
        updated_at:
          format: date-time
          type: string
        archived_at:
          format: date-time
          type: string
          nullable: true
      required:
      - id
      - external_id
      - name
      - variants
      - status
      - created_at
      - updated_at
      type: object
    PaginatedProducts:
      additionalProperties: false
      description: Enveloppe paginee `GET /v1/shops/{id}/products`.
      properties:
        data:
          items:
            $ref: '#/components/schemas/ProductOut'
          type: array
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
      required:
      - data
      - has_more
      type: object
    OrderDeclare:
      additionalProperties: false
      description: 'Payload `POST /v1/shops/{id}/orders`.


        Minimum : `external_id`, `customer`, `amount`, et de quoi decrire le

        contenu — `items` ou `items_text`.'
      properties:
        customer:
          $ref: '#/components/schemas/CustomerInput'
          description: 'Le client a appeler : `name` + `phone_number` requis, genre, email, notes
            et langue facultatifs.'
        items:
          items:
            $ref: '#/components/schemas/OrderItem'
          maxItems: 50
          minItems: 1
          type: array
          nullable: true
          description: 'Les articles. Une ligne reference un produit declare (`product_external_id`,
            plus `variant_external_id` s''il a plusieurs variantes) ou se decrit sur place (`name`…).
            **Figes a la declaration** : ce que le catalogue dit a ce moment-la est copie dans
            la commande, et c''est cette copie que chaque appel lira. Sur un `PATCH`, la liste
            envoyee remplace l''ancienne, et la copie est refaite.'
        items_text:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: Resume libre du contenu, cite tel quel au client. Requis si `items` est
            absent.
        delivery_address:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: 'Adresse de livraison. Absente : l''agent la collecte aupres du client
            pendant l''appel.'
        amount:
          anyOf:
          - exclusiveMinimum: 0.0
            maximum: 1000000.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          description: Montant total annonce au client, dans la devise de la commande.
        currency:
          enum:
          - MAD
          - EUR
          - USD
          - TND
          - DZD
          type: string
          nullable: true
          description: 'Devise ISO 4217. Absente : celle du pays de la boutique.'
        order_date:
          format: date
          type: string
          nullable: true
        subtotal:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        shipping_cost:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        discount:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        taxes:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        delivery_method:
          enum:
          - standard
          - express
          - pickup
          type: string
          nullable: true
        delivery_zone:
          enum:
          - urban
          - suburban
          - rural
          type: string
          nullable: true
        estimated_delivery_date:
          format: date
          type: string
          nullable: true
          description: Date de livraison estimee, que l'agent peut annoncer.
        order_notes:
          maxLength: 300
          minLength: 1
          type: string
          nullable: true
          description: 'Ce que l''agent doit savoir sur cette commande : « cadeau, ne pas mentionner
            le prix ».'
        source:
          enum:
          - web
          - pos
          - ads
          - other
          type: string
          nullable: true
        promo_code:
          maxLength: 50
          minLength: 1
          type: string
          nullable: true
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: Remise maximale accordable sur cette commande — le niveau le plus precis
            de la cascade commande > produits > boutique.
          nullable: true
        metadata:
          additionalProperties: true
          type: object
          nullable: true
          description: Metadata libre du reseller. Max 2 KB serialise JSON.
        external_id:
          description: Identifiant de la commande dans VOTRE systeme, unique par boutique. Sert
            d'adresse dans l'URL et de reference pour lancer l'appel. **Immuable** apres creation.
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          example: CMD-2026-8891
      required:
      - customer
      - amount
      - external_id
      type: object
    OrderPatch:
      additionalProperties: false
      description: 'Payload `PATCH` — modification partielle.


        Champ **absent** = inchange, champ a **`null`** = efface, chaine vide =

        422. Trois champs ne s''effacent pas — `customer`, `amount`, `items` :

        une commande a toujours un client, un montant et un contenu.'
      properties:
        customer:
          $ref: '#/components/schemas/CustomerInput'
          nullable: true
          description: 'Le client a appeler : `name` + `phone_number` requis, genre, email, notes
            et langue facultatifs. C''est lui que chaque appel sur cette commande joindra.'
        items:
          items:
            $ref: '#/components/schemas/OrderItem'
          maxItems: 50
          minItems: 1
          type: array
          nullable: true
          description: 'Les articles. Une ligne reference un produit declare (`product_external_id`,
            plus `variant_external_id` s''il a plusieurs variantes) ou se decrit sur place (`name`…).
            **Figes a la declaration** : ce que le catalogue dit a ce moment-la est copie dans
            la commande, et c''est cette copie que chaque appel lira. Sur un `PATCH`, la liste
            envoyee remplace l''ancienne, et la copie est refaite.'
        items_text:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: Resume libre du contenu, cite tel quel au client. Requis si `items` est
            absent.
        delivery_address:
          maxLength: 500
          minLength: 1
          type: string
          nullable: true
          description: 'Adresse de livraison. Absente : l''agent la collecte aupres du client
            pendant l''appel.'
        amount:
          anyOf:
          - exclusiveMinimum: 0.0
            maximum: 1000000.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: 'Montant total annonce au client, dans la devise de la commande. C''est
            ce que l''agent dit : les articles servent a detailler, pas a le calculer.'
          nullable: true
        currency:
          enum:
          - MAD
          - EUR
          - USD
          - TND
          - DZD
          type: string
          nullable: true
          description: 'Devise ISO 4217. Absente : celle du pays de la boutique.'
        order_date:
          format: date
          type: string
          nullable: true
        subtotal:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        shipping_cost:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        discount:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        taxes:
          anyOf:
          - maximum: 1000000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        delivery_method:
          enum:
          - standard
          - express
          - pickup
          type: string
          nullable: true
        delivery_zone:
          enum:
          - urban
          - suburban
          - rural
          type: string
          nullable: true
        estimated_delivery_date:
          format: date
          type: string
          nullable: true
          description: Date de livraison estimee, que l'agent peut annoncer.
        order_notes:
          maxLength: 300
          minLength: 1
          type: string
          nullable: true
          description: 'Ce que l''agent doit savoir sur cette commande : « cadeau, ne pas mentionner
            le prix ».'
        source:
          enum:
          - web
          - pos
          - ads
          - other
          type: string
          nullable: true
        promo_code:
          maxLength: 50
          minLength: 1
          type: string
          nullable: true
        max_discount:
          anyOf:
          - maximum: 10000.0
            minimum: 0.0
            type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          description: Remise maximale accordable sur cette commande — le niveau le plus precis
            de la cascade commande > produits > boutique.
          nullable: true
        metadata:
          additionalProperties: true
          type: object
          nullable: true
          description: Metadata libre du reseller. Max 2 KB serialise JSON.
        external_id:
          maxLength: 128
          minLength: 1
          pattern: ^[a-zA-Z0-9_.-]+$
          type: string
          nullable: true
          description: 'Facultatif, et purement confirmatif : s''il est present il doit valoir
            celui de l''URL. L''identifiant d''une commande ne se modifie pas.'
      type: object
    OrderOut:
      additionalProperties: false
      description: Fiche complete d'une commande declaree.
      properties:
        id:
          format: uuid
          type: string
        external_id:
          type: string
        customer:
          $ref: '#/components/schemas/OrderCustomerOut'
          nullable: true
        items:
          items:
            $ref: '#/components/schemas/OrderItemOut'
          type: array
        items_text:
          type: string
          nullable: true
        delivery_address:
          type: string
          nullable: true
        amount:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        currency:
          type: string
          nullable: true
        order_date:
          format: date
          type: string
          nullable: true
        subtotal:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        shipping_cost:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        discount:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        taxes:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        delivery_method:
          type: string
          nullable: true
        delivery_zone:
          type: string
          nullable: true
        estimated_delivery_date:
          format: date
          type: string
          nullable: true
        order_notes:
          type: string
          nullable: true
        source:
          type: string
          nullable: true
        promo_code:
          type: string
          nullable: true
        max_discount:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        metadata:
          additionalProperties: true
          type: object
          nullable: true
        order_status:
          description: 'Ou en est la commande : `PENDING` avant tout appel abouti, puis `CONFIRMED`,
            `CANCELLED`, `UNREACHABLE`… selon le resultat de l''appel. **Jamais modifiable a la
            main** : il se lit.'
          type: string
        calls:
          description: Les appels passes sur cette commande, du plus ancien au plus recent.
          items:
            $ref: '#/components/schemas/OrderCallOut'
          type: array
        call_in_progress:
          description: 'Vrai quand un appel n''est pas termine : la commande ne peut alors ni
            etre modifiee, ni archivee, ni rappelee.'
          type: boolean
        status:
          enum:
          - active
          - archived
          type: string
        created_at:
          format: date-time
          type: string
        updated_at:
          format: date-time
          type: string
        archived_at:
          format: date-time
          type: string
          nullable: true
      required:
      - id
      - external_id
      - items
      - order_status
      - calls
      - call_in_progress
      - status
      - created_at
      - updated_at
      type: object
    OrderCustomerOut:
      additionalProperties: false
      description: Le client d'une commande, tel que renvoye.
      properties:
        name:
          type: string
          nullable: true
        phone_number:
          type: string
        gender:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        language:
          type: string
          nullable: true
      required:
      - phone_number
      type: object
    OrderItemOut:
      description: 'Un article de la copie figee.


        Les cles inconnues sont ignorees a la lecture : la copie d''une

        commande ancienne peut porter des cles d''un format anterieur.'
      properties:
        name:
          type: string
          nullable: true
        quantity:
          default: 1
          type: integer
        unit_price:
          anyOf:
          - type: number
          - pattern: ^[0-9]+([.][0-9]+)?$
            type: string
            example: '250.00'
          - type: 'null'
          nullable: true
        variants:
          items:
            $ref: '#/components/schemas/OrderItemVariant'
          type: array
          nullable: true
        available_variants:
          items:
            items:
              $ref: '#/components/schemas/OrderItemVariant'
            type: array
          type: array
          nullable: true
        description:
          type: string
          nullable: true
        sku:
          type: string
          nullable: true
        product_url:
          type: string
          nullable: true
        product_external_id:
          type: string
          nullable: true
        variant_external_id:
          type: string
          nullable: true
        product_id:
          type: string
          nullable: true
        variant_id:
          type: string
          nullable: true
      type: object
      additionalProperties: false
    OrderCallOut:
      additionalProperties: false
      description: 'Un appel passe sur la commande, en resume. Le detail complet se lit

        sur `GET /v1/calls/{id}`.'
      properties:
        id:
          type: string
        request_status:
          type: string
        call_status:
          type: string
        result:
          type: string
          nullable: true
        result_detail:
          type: string
          nullable: true
        created_at:
          format: date-time
          type: string
      required:
      - id
      - request_status
      - call_status
      - created_at
      type: object
    PaginatedOrders:
      additionalProperties: false
      description: Enveloppe paginee `GET /v1/shops/{id}/orders`.
      properties:
        data:
          items:
            $ref: '#/components/schemas/OrderOut'
          type: array
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
      required:
      - data
      - has_more
      type: object
    OrderItemVariant:
      type: object
      additionalProperties: false
      description: "Une paire nom/valeur d'une variante, telle que citee au client (« Taille : XL »)."
      properties:
        name: { type: string, maxLength: 40 }
        value: { type: string, maxLength: 80 }
      required: [name, value]
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          example: https://docs.yasmine.akidly.com/errors/invalid_api_key
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        request_id:
          type: string
          example: a1b2c3d4
          description: 8 chars hex. À inclure dans tout ticket support.
    # -----------------------------------------------------------------------
    # Pagination
    # -----------------------------------------------------------------------
    Page:
      type: object
      required: [data, has_more]
      properties:
        data:
          type: array
          items: {}
        has_more: { type: boolean }
        next_cursor: { type: string, nullable: true }
    # -----------------------------------------------------------------------
    # CallCreate / CallOut
    # -----------------------------------------------------------------------
    CustomerInput:
      type: object
      required: [name, phone_number]
      additionalProperties: false
      description: |
        Sous-objet `customer` du payload `POST /v1/calls` (Phase 2A).
        Identité client + contact + métadata facultative (gender, email,
        notes, language). `name` et `phone_number` requis, le reste
        facultatif.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Nom du client. Strip automatique des espaces en début/fin,
            rejet des caractères de contrôle ASCII, normalisation
            Unicode NFC.
          examples: ["Ahmed Bennani"]
        phone_number:
          type: string
          description: |
            Numéro client au format E.164 (ex. `+212612345678`). Validé
            par libphonenumber (`is_valid_number`) et renormalisé en
            E.164 canonique avant traitement. Les espaces et séparateurs
            sont tolérés en entrée mais supprimés.
          examples: ["+212612345678"]
        gender:
          type: string
          enum: [male, female, unknown]
          nullable: true
          description: |
            Genre du client. Utilisé pour adapter les formules de
            politesse du dialogue. Mémorisé sur la fiche client
            (COALESCE stickiness — une valeur posée n'est pas écrasée
            par un POST ultérieur sans ce champ).
        email:
          type: string
          nullable: true
          maxLength: 100
          pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"
          description: |
            Email du client (validation format boundary, cap 100 chars).
            Mémorisé sur la fiche client (conservé si déjà renseigné).
          examples: ["ahmed@example.com"]
        notes:
          type: string
          nullable: true
          maxLength: 500
          description: |
            Notes libres sur le client (500 chars max). Visible côté
            dialogue en contexte de la confirmation. Persisté dans
            les notes de la fiche client (conservées si déjà renseignées).
        language:
          type: string
          nullable: true
          maxLength: 8
          description: |
            Langue préférée du client (`ar`, `fr`). Propagé à
            la langue du client et celle par défaut de la boutique.
          examples: ["ar", "fr"]
    ShopInfoInput:
      type: object
      required: [name]
      additionalProperties: false
      description: |
        Sous-objet `shop_info`. Profil boutique : identité, coordonnées,
        politiques, commercial. `name` requis, reste optionnel. Un champ
        absent ou `null` **n'efface jamais** une valeur déjà enregistrée :
        un appel enrichit la fiche, il ne la détruit pas.

        **Préférez `/v1/shops`.** La fiche boutique se déclare une fois
        via `POST /v1/shops`, puis se modifie par `PATCH` — inutile de
        la retransmettre à chaque appel. `shop_info` reste supporté pour
        les intégrations qui envoient tout en une requête. Attention si
        vous mélangez les deux : le `PATCH` efface sur `null`, `shop_info`
        non — mieux vaut choisir une seule porte par boutique.

        ### Changements du 2026-09-01

        **Renommés** (l'ancien nom renvoie désormais un 422) :

        | Avant | Après |
        |---|---|
        | `shop_phone_sav` | `phone` |
        | `shop_email_sav` | `email` |
        | `shop_whatsapp_sav` | `whatsapp` |
        | `size_exchange_policy` | `exchange_policy` |

        **Retirés du contrat** — les envoyer renvoie un 422 :

        - `loyalty_program` — programme de fidélité

        **Déprécié** : `industry`, `active`, `logo_url` restent acceptés et
        enregistrés, mais ne sont pas exploités et disparaîtront après
        préavis `Sunset`.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Nom commercial affiché côté agent vocal + dashboard.
          examples: ["GlowArt Casa"]
        sector: { type: string, nullable: true, maxLength: 80 }
        merchant_external_id:
          type: string
          nullable: true
          maxLength: 128
          pattern: "^[a-zA-Z0-9_.-]+$"
          example: "M-4471"
          description: >-
            Identifiant du commerçant propriétaire de cette boutique, tel
            qu'il existe dans votre système. Sert à **regrouper plusieurs
            boutiques sous un même commerçant** : `GET /v1/shops` accepte
            ce champ en filtre.

            ⚠️ À ne pas confondre avec `shop_external_id`, qui identifie
            LA boutique. Celui-ci identifie son propriétaire, et plusieurs
            boutiques partagent la même valeur — c'est son objet.

            C'est une clé de regroupement, pas une fiche : le commerçant
            n'a pas d'attributs propres aujourd'hui (ni nom, ni contact).
            Si ce besoin apparaît, une ressource dédiée sera ajoutée sans
            que ce champ change de sens.
        has_physical_store:
          type: boolean
          nullable: true
          description: >-
            La boutique dispose-t-elle d'un point de vente physique où le
            client peut se présenter ? `true` oui, `false` vente à distance
            uniquement. Absent ou `null` vaut `false` — le troisième état
            « non déclaré » a été retiré le 2026-09-03.
            **Distinct de `address`** : celle-ci dit *où* vous êtes,
            celui-ci dit si le client *peut venir* — un vendeur en ligne a
            souvent une adresse (entrepôt, domicile) qu'il ne souhaite pas
            voir communiquée comme lieu de visite. Enregistré sur la fiche
            boutique, pas encore exploité pendant l'appel — activation
            prévue.
        industry:
          type: string
          nullable: true
          maxLength: 32
          deprecated: true
          description: >-
            **Déprécié (2026-09-01).** Fait doublon avec `sector`, seul
            champ de classement exploité pendant l'appel. Toujours accepté
            et enregistré pour ne casser aucune intégration existante ;
            retrait après préavis `Sunset`. Ne rien construire dessus.
        active:
          type: boolean
          nullable: true
          deprecated: true
          description: >-
            **Déprécié (2026-09-01).** Remplacé par l'archivage de la fiche
            boutique (`DELETE /v1/shops/{shop_external_id}`), qui
            expose l'état réel via le champ `status`.
        logo_url:
          type: string
          nullable: true
          maxLength: 255
          deprecated: true
          description: >-
            **Déprécié (2026-09-01).** Jamais exploité pendant l'appel.
        phone:
          type: string
          nullable: true
          maxLength: 30
          description: >-
            Téléphone de la boutique, communicable au client pendant
            l'appel. **Renomme `shop_phone_sav` depuis le 2026-09-01** : la
            distinction service après-vente / ligne principale n'avait pas
            de réalité pour la majorité des boutiques. L'ancien nom est
            désormais refusé (422).
        email:
          type: string
          nullable: true
          maxLength: 50
          pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"
          description: >-
            Email de contact de la boutique. **Renomme `shop_email_sav`
            depuis le 2026-09-01.** L'ancien nom est refusé (422).
        whatsapp:
          type: string
          nullable: true
          maxLength: 30
          description: >-
            Numéro WhatsApp de la boutique. **Renomme `shop_whatsapp_sav`
            depuis le 2026-09-01.** L'ancien nom est refusé (422).
        address: { type: string, nullable: true, maxLength: 200 }
        customer_service_hours:
          type: string
          nullable: true
          maxLength: 100
          description: >-
            Horaires du service client, en texte libre (ex. « Lun-Sam
            9h-18h »). L'agent s'en sert pour répondre au client qui
            demande quand vous joindre.
        website_url:
          type: string
          nullable: true
          maxLength: 255
          description: Site web de la boutique.
        delivery_policy: { type: string, nullable: true, maxLength: 250 }
        delivery_delay:
          type: string
          nullable: true
          maxLength: 100
          description: >-
            Délai générique de livraison de la boutique (ex. « 8 heures »,
            « 24-48h »). Utilisé pour répondre au client qui demande quand
            sa commande arrivera, lorsque la commande n'a pas de
            `estimated_delivery_date` (la date de la commande reste
            prioritaire). Distinct de `delivery_policy` (politique et
            conditions de livraison).
        payment_policy: { type: string, nullable: true, maxLength: 250 }
        return_policy: { type: string, nullable: true, maxLength: 250 }
        exchange_policy:
          type: string
          nullable: true
          maxLength: 250
          description: >-
            Conditions d'échange d'un article : taille, couleur, modèle,
            article défectueux. **Renomme `size_exchange_policy` depuis le
            2026-09-01** — l'ancien nom restreignait la réponse aux seules
            questions de taille. L'ancien nom est refusé (422).
        warranty_policy: { type: string, nullable: true, maxLength: 250 }
        inspection_before_payment:
          type: boolean
          nullable: true
          description: >-
            Le client peut-il ouvrir le colis avant de payer ? `true`
            autorisé, `false` refusé. Absent ou `null` vaut `false` — le
            troisième état « non déclaré » a été retiré le 2026-09-03,
            faute d'avoir jamais produit une différence de comportement.
            Enregistré sur la fiche boutique, pas encore exploité pendant
            l'appel — activation prévue.
        current_promotions: { type: string, nullable: true, maxLength: 250 }
        faq_free: { type: string, nullable: true, maxLength: 500 }
    OrderItem:
      type: object
      additionalProperties: false
      description: |
        Article structuré d'une commande (entrée de `OrderInput.items`).
        Optionnel côté contrat : un POST peut envoyer une liste structurée
        d'items, OU un résumé libre via `items_text`, OU les deux. Si les
        deux sont fournis, la liste structurée prime au moment de l'appel.
      properties:
        product_external_id:
          type: string
          nullable: true
          maxLength: 128
          pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$"
          examples: ["TSHIRT-BLANC"]
          description: |
            Renvoie a un produit declare dans le catalogue de la boutique.
            Son nom, sa description, son prix et ses declinaisons sont alors
            lus depuis le catalogue : inutile de les repeter a chaque
            commande. Ce que vous fournissez malgre tout l'emporte — un prix
            promotionnel valable pour cette commande-la reste possible.
            Sans ce champ, decrivez l'article sur place avec `name` : c'est
            la voie pour un article sur mesure, des frais de port ou un
            cadeau. Un produit absent du catalogue ne bloque jamais un appel.
        variant_external_id:
          type: string
          nullable: true
          maxLength: 128
          pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$"
          examples: ["TSHIRT-BLANC-XL"]
          description: |
            Precise quelle declinaison du produit — taille, couleur. Inutile
            si le produit n'en a qu'une : elle est retenue automatiquement.
            S'il en a plusieurs et qu'aucune n'est designee, la requete est
            refusee en listant celles qui existent
            (`order_item_variant_required`). Aucune n'est choisie au hasard :
            l'appel annoncerait au client un article qu'il n'a pas commande.
        name:
          type: string
          minLength: 1
          maxLength: 150
          description: Désignation de l'article (ex. "T-shirt blanc en coton").
          examples: ["T-shirt blanc en coton"]
        quantity:
          type: integer
          minimum: 1
          maximum: 999
          default: 1
          description: Nombre d'unités pour cet article. Défaut 1.
        variants:
          type: array
          nullable: true
          maxItems: 3
          description: |
            Variantes nommées de l'article (max 3). Chaque entrée est une
            paire `name` (type — "Taille", "Couleur", "Pointure", ... ou
            un type libre) + `value` ("XL", "rouge"). Citées au client
            sous la forme « Taille : XL ».
          items:
            type: object
            required: [name, value]
            additionalProperties: false
            properties:
              name:
                type: string
                minLength: 1
                maxLength: 40
                description: Type de variante ("Taille", "Couleur", ...).
              value:
                type: string
                minLength: 1
                maxLength: 80
                description: Valeur ("XL", "rouge").
        available_variants:
          type: array
          nullable: true
          maxItems: 60
          description: |
            Combinaisons de variantes réellement disponibles pour cet
            article (max 60). Chaque combinaison est une liste de paires
            `name`/`value` (1 à 3, mêmes axes que `variants`). Fournir la
            liste des combinaisons qui existent, pas le produit cartésien :
            elle exprime les croisements — une couleur peut n'être
            disponible que dans certaines tailles. Quand ce champ est
            présent, l'agent ne propose et n'accepte QUE ces combinaisons ;
            toute autre valeur est annoncée comme indisponible. Absent :
            l'agent s'appuie sur `description` et les infos boutique,
            sinon sur la seule variante commandée.
          items:
            type: array
            minItems: 1
            maxItems: 3
            items:
              type: object
              required: [name, value]
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 40
                  description: Type de variante ("Taille", "Couleur", ...).
                value:
                  type: string
                  minLength: 1
                  maxLength: 80
                  description: Valeur ("XL", "rouge").
        description:
          type: string
          nullable: true
          maxLength: 500
          description: |
            Description libre du produit (matière, contenu, usage...).
            Sert à répondre aux questions du client sur l'article ;
            n'est pas récitée d'office.
        unit_price:
          type: string
          nullable: true
          pattern: "^[0-9]+(\\.[0-9]+)?$"
          description: |
            Prix unitaire dans la devise de la commande (string, précision
            Decimal). Optionnel — omettre pour un article au prix non
            spécifié.
          examples: ["120.00"]
    OrderInput:
      type: object
      required: [amount]
      additionalProperties: false
      description: |
        Sous-objet `order`. Commande du client : logistique,
        montants, métadata commerciale. `amount` et `currency` requis.
        `delivery_address` optionnelle depuis 2026-08-22 : absente (ou
        réduite à un nom de ville), l'agent collecte l'adresse auprès du
        client pendant l'appel et la remonte dans `preferences` du compte
        rendu. `external_id` optionnel — génération fallback
        côté serveur si absent. **INSERT strict** : un 2e POST avec même
        `(shop_id, external_id)` retourne `409 order_external_id_already_exists`.

        **Détail des articles** : deux formes au choix, l'une ou l'autre,
        ou les deux à la fois.
        - `items` : liste structurée article par article (recommandé quand
          la commande contient plusieurs articles distincts cités par nom).
        - `items_text` : chaîne libre (recommandé pour les commandes à
          article unique ou quand le détail n'est pas découpé côté reseller).

        Les deux champs sont optionnels — une commande sans aucun des deux
        est valide.
      properties:
        max_discount:
          type: string
          nullable: true
          pattern: '^[0-9]+([.][0-9]+)?$'
          examples: ["30.00"]
          description: |
            Remise maximale accordable sur CETTE commande, dans la devise de
            la commande. Niveau le plus precis : fourni, il l'emporte sur
            celui des produits et sur celui de la boutique. Absent, la valeur
            est determinee cote serveur — la plus haute parmi les produits
            de la commande qui en declarent une, sinon celle de la boutique.
            Enregistre, pas encore exploite pendant l'appel.
        external_id:
          type: string
          nullable: true
          maxLength: 128
          description: ID commande côté reseller, unique par boutique.
        delivery_address:
          type: string
          nullable: true
          maxLength: 500
          description: |
            Adresse de livraison. Optionnelle : si absente ou réduite à un
            nom de ville, l'agent la collecte pendant l'appel (l'adresse
            confirmée remonte dans `preferences` du compte rendu).
        items:
          type: array
          nullable: true
          maxItems: 50
          items:
            $ref: "#/components/schemas/OrderItem"
          description: |
            Liste structurée d'articles (max 50). Chaque entrée : `name`
            requis + `quantity` (défaut 1) + `variants` (paires
            nom/valeur, max 3) + `description` + `unit_price`
            optionnels. Laisser absent ou `null` pour une commande sans
            détail structuré (utiliser `items_text` à la place).
        items_text:
          type: string
          nullable: true
          maxLength: 500
          description: |
            Résumé libre du contenu de la commande (ex. "t-shirt blanc et
            jean noir XXL"). Utilisé quand `items` n'est pas fourni ou
            est vide. Si `items` est aussi présent, la liste structurée
            prime au moment de l'appel.
        amount:
          type: string
          pattern: "^[0-9]+(\\.[0-9]+)?$"
          description: Montant total en devise indiquée (string, précision Decimal).
          examples: ["249.00"]
        currency:
          type: string
          enum: [MAD, EUR, USD, TND, DZD]
        order_date: { type: string, nullable: true, format: date }
        subtotal: { type: string, nullable: true }
        shipping_cost: { type: string, nullable: true }
        discount: { type: string, nullable: true }
        taxes: { type: string, nullable: true }
        delivery_method:
          type: string
          nullable: true
          enum: [standard, express, pickup]
        delivery_zone:
          type: string
          nullable: true
          enum: [urban, suburban, rural]
        estimated_delivery_date: { type: string, nullable: true, format: date }
        previous_attempts: { type: integer, nullable: true, minimum: 0, maximum: 100 }
        order_notes: { type: string, nullable: true, maxLength: 300 }
        source:
          type: string
          nullable: true
          enum: [web, pos, ads, other]
        promo_code: { type: string, nullable: true, maxLength: 50 }
    CallParamsInput:
      type: object
      required: [purpose]
      additionalProperties: false
      description: |
        Sous-objet `call_params`. Paramètres de l'appel :
        intention, tone, durée max, voix. `purpose` requis (enum interne).
      properties:
        purpose:
          type: string
          enum: [confirmation, retry, followup_delivery, satisfaction, custom]
          description: Intention métier de l'appel .
        channel:
          type: string
          nullable: true
          enum: [gsm, whatsapp]
          description: |
            Par où l'appel sort. **Omettez ce champ** sauf besoin
            particulier : le canal par défaut de votre installation
            s'applique, et c'est le bon dans l'immense majorité des cas.

            - `gsm` — appel téléphonique classique, reçu comme n'importe
              quel appel sur le réseau mobile du client.
            - `whatsapp` — appel WhatsApp Business natif.

            **Tous les canaux ne sont pas ouverts partout.** Demander un
            canal fermé renvoie `422 channel_not_available`, dont le
            `detail` nomme ceux qui sont disponibles et le canal par
            défaut. Le refus est volontairement explicite : un appel
            silencieusement re-routé vers un autre canal que celui demandé
            serait indétectable de votre côté.

            La demande est **toujours acceptée** : l'appel part dès que
            possible, au plus tard dans les 48 heures. Passé ce délai sans
            avoir pu partir, il est abandonné et le compte rendu le dit.
        tone:
          type: string
          nullable: true
          enum: [formal, friendly, default]
        max_call_duration_seconds:
          type: integer
          nullable: true
          minimum: 30
          maximum: 600
          description: >-
            Durée maximale souhaitée pour l'appel, en secondes.
            **Déclaratif** : la valeur est enregistrée sur la fiche
            boutique mais **aucun plafond n'est appliqué aujourd'hui** —
            implémentation prévue. Documenté explicitement depuis le
            2026-09-01 : le champ existait depuis des mois sans que son
            absence d'effet soit dite.
        voice_id:
          type: string
          nullable: true
          maxLength: 32
          description: >-
            Identifiant de voix souhaité pour l'appel. **Déclaratif** : la
            valeur est enregistrée mais la voix reste sélectionnée par le
            pays de l'appel — implémentation prévue. Documenté
            explicitement depuis le 2026-09-01, même raison que
            `max_call_duration_seconds`.
    CallCreate:
      type: object
      required: [shop_external_id, call_params]
      additionalProperties: false
      description: |
        Payload `POST /v1/calls` — structure complète imbriquée.
        BREAKING vs précédentes versions : `shop_ref` renommé
        `shop_external_id`, `amount`/`currency` déplacés dans `order`,
        `purpose` déplacé dans `call_params`.
      properties:
        customer:
          $ref: "#/components/schemas/CustomerInput"
        shop_external_id:
          type: string
          minLength: 1
          maxLength: 128
          pattern: "^[a-zA-Z0-9_.-]+$"
          description: |
            Slug stable identifiant la boutique côté reseller. Utilisé pour
            l'upsert boutique. Caractères autorisés : `[a-zA-Z0-9_.-]`.
            Remplace `shop_ref` des versions précédentes.
          examples: ["boutique-paris-01"]
        shop_info:
          $ref: "#/components/schemas/ShopInfoInput"
        order:
          $ref: "#/components/schemas/OrderInput"
        order_external_id:
          type: string
          minLength: 1
          maxLength: 128
          pattern: "^[a-zA-Z0-9_.-]+$"
          description: |
            **L'appel par référence.** Identifiant d'une commande déjà
            déclarée via `POST /v1/shops/{id}/orders`, dans la boutique de
            `shop_external_id`. Le serveur relit tout — client, articles
            figés, montants — et rattache l'appel à la commande. Relancer le
            client se fait avec la même référence : les appels s'empilent
            sur la commande, jamais de doublon. Refusé
            (`409 order_call_in_progress`) si un appel est déjà en cours.

            **Exactement l'un des deux** : `order` (commande décrite sur
            place, avec `customer`), ou `order_external_id` (sans
            `customer`, qui est celui de la commande).
          examples: ["CMD-2026-8891"]
        country:
          type: string
          enum: [MA, DZ, TN, FR]
          description: |
            Code pays ISO 3166-1 alpha-2 du client à appeler. Détermine
            le profil conversationnel et la **langue par défaut**
            appliquée si `language` n'est pas fourni : `MA`/`DZ`/`TN` →
            `ar`, `FR` → `fr`. Les profils conversationnels internes
            sont choisis côté serveur — transparent pour le reseller.
          examples: [MA]
        language:
          type: string
          nullable: true
          enum: [ar, fr]
          description: |
            Langue de l'appel. **Optionnel** — si absent, applique la
            langue locale du pays (`MA`/`DZ`/`TN` → `ar`, `FR` → `fr`).
            Permet à un reseller dont le client préfère le français
            d'avoir un appel en français même depuis un pays maghrébin
            (combinaisons `MA`/`DZ`/`TN` + `fr` toutes acceptées).

            **Combinaison rejetée** : `country=FR` + `language=ar` →
            422 `language_not_supported_for_country` (cf
            [errors](https://docs.yasmine.akidly.com/errors)).

            **Rétrocompat** : un payload sans ce champ se comporte
            exactement comme avant l'introduction (langue locale
            appliquée). Aucune migration côté reseller requise.
          examples: ["fr"]
        call_params:
          $ref: "#/components/schemas/CallParamsInput"
        metadata:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Metadata libre du reseller (propagé dans
            vos métadonnées). Limite : 2 KB
            sérialisé JSON compact.
    CallOut:
      type: object
      required: [id, request_status, call_status, created_at, shop_id, customer_phone_masked]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        request_status:
          type: string
          enum:
            - queued
            - waiting_user_permission
            - permission_accepted
            - permission_refused
            - permission_expired
            - quota_blocked
            - cancelled_by_reseller
            - ready_to_dial
            - done
          description: |
            Rail DEMANDE côté Yasmine. Cycle nominal :
            `queued → waiting_user_permission → permission_accepted →
            ready_to_dial → done`. Terminaux échec : `permission_refused`,
            `permission_expired`, `quota_blocked`, `cancelled_by_reseller`.

            `quota_blocked` couvre 3 cas de quotas guards
            proactifs (cf `doc/whatsapp.md §4.2-§4.3`) :
            - `templates_quota_24h` : 1 call_permission_request déjà envoyé dans les 24 h glissantes.
            - `templates_quota_7d` : 2 déjà envoyés dans les 7 j glissants.
            - `auto_revoke_imminent_no_answer_streak_4` : 4 NO_ANSWER consécutifs → l'opérateur considère la permission auto-révoquée.
            Le diagnostic exact est conservé sous la forme
            `quota:<reason>`. Non exposé dans la réponse 201
            initial (transition asynchrone via background task).
        call_status:
          type: string
          enum: [not_started, dialing, ringing, connected, ended, failed, cancelled]
          description: |
            Rail APPEL côté téléphonie. Pilotée par les webhooks
            entrants une fois `request_status = ready_to_dial`. Cycle :
            `not_started → dialing → ringing → connected → ended`.
            Terminaux échec : `failed`, `cancelled` (via POST
            `/v1/calls/{id}/cancel`).
        created_at: { type: string, format: date-time }
        shop_id:
          type: string
          format: uuid
          description: ID du boutique upserté depuis `shop_ref`.
        customer_phone_masked:
          type: string
          description: |
            Numéro masqué pour les logs / UI (ex. `+212 6** *** *78`).
            Le numéro brut n'est **jamais** retourné dans les réponses
            API — disponible uniquement côté ops pour investigation.
          examples: ["+212 6** *** *78"]
        purpose:
          type: string
          nullable: true
        amount:
          type: string
          nullable: true
          examples: ["249.00"]
        currency:
          type: string
          nullable: true
          pattern: "^[A-Z]{3}$"
          examples: [MAD]
        country:
          type: string
          nullable: true
          enum: [MA, DZ, TN, FR, null]
          description: Code ISO echoed du reseller — jamais le profil conversationnel interne.
        language:
          type: string
          nullable: true
          enum: [ar, fr, null]
          description: |
            Langue effectivement utilisée pour cet appel. Echo de la
            valeur fournie dans le payload, ou langue locale appliquée
            par défaut (`MA`/`DZ`/`TN` → `ar`, `FR` → `fr`). `null`
            sur les anciens appels antérieurs à l'introduction du
            champ.
          examples: ["ar"]
        metadata:
          type: object
          nullable: true
          additionalProperties: true
        # Champs cycle de vie + terminal. Tous nullable : `null`
        # tant que l'etape n'est pas atteinte.
        ringing_at:
          type: string
          format: date-time
          nullable: true
          description: Horodatage `ringing` (sonnerie cote client).
        connected_at:
          type: string
          format: date-time
          nullable: true
          description: Horodatage `connected` (client a decroche). Origine DB `accepted_at`.
        ended_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Horodatage de fin (terminal naturel ou cancel). `null` tant
            que l'appel n'est pas en etat `ended`/`failed`/`cancelled`.
        call_duration_seconds:
          type: integer
          nullable: true
          minimum: 0
          examples: [37]
          description: |
            Durée réelle de la conversation en secondes. `null` tant que
            l'appel n'est pas terminé.
        billable_duration_seconds:
          type: integer
          nullable: true
          minimum: 0
          examples: [37]
          description: |
            Durée facturée pour cet appel. Égale à
            `max(call_duration_seconds, 10)` par règle de facturation
            (plancher 10 s). `null` tant que l'appel n'est pas finalisé.
        result:
          type: string
          nullable: true
          enum: [confirmed, cancelled, requires_action, null]
          description: |
            Résultat principal de l'appel. `confirmed` = la commande a été
            confirmée par le client (avec ou sans modification — voir
            `result_detail`). `cancelled` = la commande a été annulée
            (par le client ou faux numéro). `requires_action` = aucune
            décision automatique, le marchand doit traiter manuellement
            (rappel, attente d'une date, audio inaudible, etc. — voir
            `result_detail`). `null` tant que l'appel n'est pas terminé.
        result_detail:
          type: string
          nullable: true
          description: |
            Nuance fine du résultat (slug texte libre). Exemples :
            `modified` (commande confirmée avec modification),
            `wrong_number`, `denied_order`, `human_requested`,
            `price_dispute`, `postponed`, `callback`, `unconfirmed`,
            `unclear`, `no_answer`, `failed`. `null` quand `result`
            est `confirmed` ou `cancelled` sans nuance, ou tant que
            l'appel n'est pas terminé.
        customer_mood:
          type: string
          nullable: true
          enum: [positive, neutral, negative, frustrated, unclear, null]
          description: |
            Humeur perçue du client pendant l'appel. `unclear` est posé
            quand l'audio est globalement incompréhensible (corrélé
            généralement avec `result_detail=unclear`). `null` si non
            détectable ou tant que l'appel n'est pas terminé.
        flags:
          type: array
          nullable: true
          items: { type: string }
          description: |
            Drapeaux qualitatifs levés pendant l'appel. Valeurs
            courantes : `wrong_number`, `human_requested`, `price_dispute`,
            `address_incomplete`, `denied_order`, `audio_quality_bad`,
            `confirmed_by_relative`. Liste vide ou `null` si aucun drapeau.
        preferences:
          type: array
          nullable: true
          items: { type: string }
          description: |
            Préférences ou demandes spécifiques exprimées par le client
            (date de livraison souhaitée, créneau horaire, modification
            produit, numéro alternatif, etc.). Format texte court, une
            entrée par préférence. Liste vide ou `null` si aucune.
        next_action:
          type: string
          nullable: true
          maxLength: 200
          description: |
            Action de suivi suggérée pour la boutique (ex. `rappeler à 16h`,
            `envoyer SMS confirmation`). `null` si aucune action requise.
        summary:
          type: string
          nullable: true
          maxLength: 600
          description: |
            Résumé textuel de la conversation (1-3 phrases courtes).
            Peut contenir des informations PII du client (nom, adresse)
            telles qu'évoquées pendant l'appel. `null` tant que l'appel
            n'est pas terminé.
        failure_reason:
          type: string
          nullable: true
          description: |
            Slug machine-readable de la raison d'echec si applicable.
            Exemples : `quota:templates_24h_limit`, `permission_refused`,
            `technical_failure`, `reclaim_after_restart`.
        meta_error_code:
          type: integer
          nullable: true
          examples: [138021]
          description: |
            Code d'erreur de l'opérateur de messagerie sur appels échoués.
            Renseigné uniquement quand `result=FAILED` et que l'opérateur
            a fourni un code structuré. `null` sinon.
        meta_error_title:
          type: string
          nullable: true
          maxLength: 500
          description: |
            Libellé textuel de l'erreur opérateur correspondant à
            `meta_error_code`. Renseigné uniquement quand `result=FAILED`
            et que l'opérateur a fourni un libellé. `null` sinon.
        recording_url:
          type: string
          nullable: true
          description: |
            URL **relative** pour télécharger l'enregistrement audio
            mixte (client + agent) au format WAV mono 16 kHz.
            Format : `/v1/calls/{call_id}/recording`. Authentification
            Bearer identique aux autres endpoints `/v1`. `null` tant
            que l'appel n'est pas terminé, ou si l'audio a été purgé
            après la fenêtre de rétention (30 jours). L'endpoint peut
            renvoyer `410 Gone` (slug `recording_gone`) dans ce dernier
            cas.
        cancelled_state:
          type: string
          nullable: true
          enum: [not_started, dialing, ringing, connected, ended, failed, cancelled, null]
          description: |
            Si l'appel a ete annule par le reseller (`POST /v1/calls/{id}/cancel`),
            etat du rail APPEL au moment du cancel. Origine DB
            le champ `cancelled_state` de la réponse.
    BalanceOut:
      type: object
      required: [balance_seconds, updated_at]
      additionalProperties: false
      properties:
        balance_seconds:
          type: integer
          description: |
            Solde en secondes facturables. Peut être négatif dans la
            limite du découvert autorisé (300 s par défaut).
          examples: [3600]
        currency:
          type: string
          nullable: true
          description: |
            Code ISO 4217 si la facturation est en monnaie. `null`
            aujourd'hui (balance en secondes pures).
        updated_at:
          type: string
          format: date-time
          description: |
            Horodatage du dernier mouvement ledger (`MAX(credit_transactions.
            created_at)`) ou `created_at` du reseller en fallback.
    CallCancelOut:
      type: object
      required: [id, status, cancelled_state, billed_seconds, cancelled_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [cancelled, ended, failed]
          description: |
            `cancelled` si le cancel a eu effet. `ended`/`failed`/`cancelled`
            préexistant si l'appel était déjà terminal (idempotence).
        cancelled_state:
          type: string
          enum: [not_started, dialing, ringing, connected, ended, failed, cancelled]
          description: |
            `call_status` au moment où Yasmine a traité le cancel. Utile
            pour diagnostic : indique à quel stade du flow l'annulation
            a été reçue. Sur réponse idempotente, reflète l'état courant.
        billed_seconds:
          type: integer
          minimum: 0
          description: |
            Secondes facturées. 0 si annulé avant `connected`.
            Sinon `ceil(now - accepted_at)` avec plancher minimum 10 s.
        cancelled_at: { type: string, format: date-time }
    CallList:
      allOf:
        - $ref: "#/components/schemas/Page"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/CallOut" }
    PaginatedCalls:
      type: object
      required: [data, has_more]
      additionalProperties: false
      description: |
        Enveloppe paginée `GET /v1/calls`. `next_cursor` est un
        curseur opaque signé HMAC-SHA256 (TTL 24 h) à passer tel quel au
        `?cursor=` de la requête suivante. `null` = dernière page.
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/CallOut" }
        next_cursor:
          type: string
          nullable: true
          description: Cursor pour la page suivante. `null` si dernière page.
        has_more:
          type: boolean
          description: |
            Redondant avec `next_cursor is not null` — exposé pour
            simplifier le code client (pas besoin de comparer à `null`).
    # -----------------------------------------------------------------------
    # Account
    # -----------------------------------------------------------------------
    Reseller:
      type: object
      description: |
        Legacy schema — conservé pour compat usage interne. Le contrat
        public `GET /v1/me` retourne `MeOut` (5 champs, anti-BOPLA).
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        contact_email: { type: string, format: email, nullable: true }
        status: { type: string, enum: [active, suspended, closed] }
        created_at: { type: string, format: date-time }
    MeOut:
      type: object
      description: |
        Réponse `GET /v1/me`. Anti-BOPLA : ni `balance_seconds`
        (voir `/v1/me/balance`), ni `status`, ni aucun compteur interne.

        Les 10 champs profil société (owner_*, default_language, country,
        timezone, legal_name, tax_id, billing_address, business_type,
        website_url) sont tous `null` tant que la fiche société n'a pas
        été renseignée en DB côté ops. Aucun endpoint d'update self-service
        aujourd'hui (roadmap M4).
      required: [reseller_id, name, created_at, rate_limit_per_minute]
      additionalProperties: false
      properties:
        reseller_id: { type: string, format: uuid }
        name: { type: string }
        email:
          type: string
          nullable: true
          description: |
            Email de contact, nullable : `null` si non renseigné à la
            création.
        created_at: { type: string, format: date-time }
        rate_limit_per_minute:
          type: integer
          description: |
            Plafond requêtes/min sur `/v1/*` (valeur globale serveur,
            pas par-reseller aujourd'hui).
          example: 600
        owner_name:
          type: string
          nullable: true
          maxLength: 120
          description: "Nom du propriétaire du compte (personne physique)."
          example: "Chokri Bensassi"
        owner_phone_e164:
          type: string
          nullable: true
          maxLength: 20
          description: "Téléphone propriétaire, format E.164."
          example: "+212612345678"
        default_language:
          type: string
          nullable: true
          maxLength: 8
          description: "Langue par défaut du compte (`ar`, `fr`)."
          example: "fr"
        country:
          type: string
          nullable: true
          pattern: "^[A-Z]{2}$"
          description: |
            Code pays ISO 3166-1 alpha-2 du compte (deux lettres majuscules
            exactes).
          example: "MA"
        timezone:
          type: string
          nullable: true
          maxLength: 40
          description: "Fuseau horaire IANA."
          example: "Africa/Casablanca"
        legal_name:
          type: string
          nullable: true
          maxLength: 255
          description: "Raison sociale officielle."
          example: "MAGHREB TECHNOLOGY SENSORS SARL AU"
        tax_id:
          type: string
          nullable: true
          maxLength: 64
          description: |
            Identifiant fiscal (ICE Maroc, SIRET France, VAT EU, etc.).
          example: "002123456000099"
        billing_address:
          type: string
          nullable: true
          description: "Adresse de facturation (texte libre, multi-lignes)."
          example: "15 Rue Ibn Tofail, Casablanca"
        business_type:
          type: string
          nullable: true
          enum: [single_merchant, multi_merchant, platform]
          description: |
            Archétype du reseller : `single_merchant` (une seule boutique),
            `multi_merchant` (plusieurs boutiques gérées), `platform`
            (agrégateur / SaaS revendant Yasmine à ses propres marchands).
          example: "multi_merchant"
        website_url:
          type: string
          nullable: true
          maxLength: 255
          description: "Site commercial du reseller (URL publique)."
          example: "https://example.test"
    TransactionOut:
      type: object
      description: |
        Ligne ledger publique (`GET /v1/me/transactions`).
      required: [transaction_id, type, seconds, created_at]
      additionalProperties: false
      properties:
        transaction_id:
          type: integer
          format: int64
          description: Identifiant interne, stable et ordonné.
        type:
          type: string
          enum: [topup, debit_call, refund, adjustment]
        seconds:
          type: integer
          description: |
            Mouvement signé : positif = crédit (topup/refund),
            négatif = débit (debit_call).
        reference:
          type: string
          nullable: true
          description: |
            Référence externe. Pour `debit_call` : `call_id` UUID.
            Libre pour autres types.
        reason:
          type: string
          nullable: true
          description: Description libre (motif ops du mouvement).
        created_at: { type: string, format: date-time }
    PaginatedTransactions:
      type: object
      description: Enveloppe paginée `GET /v1/me/transactions`.
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/TransactionOut" }
        next_cursor:
          type: string
          nullable: true
          description: Cursor opaque pour la page suivante, `null` si dernière.
        has_more: { type: boolean }
    UsageStatusBreakdown:
      type: object
      description: |
        Ventilation par `call_status`. Distingue `cancelled` (cancel
        reseller) de `failed` (échec technique). In-flight non comptés.
      required: [completed_calls, completed_seconds, failed_calls, cancelled_calls]
      additionalProperties: false
      properties:
        completed_calls:
          type: integer
          description: Calls `call_status = 'ended'`.
        completed_seconds:
          type: integer
          description: |
            Somme `billable_s` des completed. Base de facturation
            effective du mois.
        failed_calls:
          type: integer
          description: Calls `call_status = 'failed'` (échecs techniques).
        cancelled_calls:
          type: integer
          description: |
            Calls `call_status = 'cancelled'` (cancel reseller via
            `POST /v1/calls/{id}/cancel`).
    WebhookTestOut:
      type: object
      description: Réponse `POST /v1/me/webhooks/test`.
      required: [test_id, delivered, latency_ms, target_url, attempted_at]
      additionalProperties: false
      properties:
        test_id:
          type: string
          format: uuid
          description: |
            Identifiant unique du test. Permet de retracer la row
            associée dans `GET /v1/me/webhooks/deliveries`.
        delivered: { type: boolean }
        http_status:
          type: integer
          nullable: true
          description: Code HTTP reçu. `null` si timeout / erreur réseau.
        latency_ms:
          type: integer
          description: Durée wall-clock du POST en millisecondes.
        target_url: { type: string }
        attempted_at: { type: string, format: date-time }
        error_message:
          type: string
          nullable: true
          description: Extrait tronqué à 200 chars si échec.
    DeliveryOut:
      type: object
      description: Ligne d'historique webhook publique. Anti-BOPLA.
      required: [delivery_id, event_type, attempt_count, status, created_at, last_attempted_at]
      additionalProperties: false
      properties:
        delivery_id:
          type: integer
          format: int64
          description: Identifiant interne, stable et ordonné.
        event_type:
          type: string
          description: |
            Ex: `call.ended`, `call.cancelled`, `webhook.test`. Non enum
            côté API — évolue au fil des events livrés.
        target_url:
          type: string
          nullable: true
          description: |
            URL au moment du POST. `null` sur les lignes antérieures.
        http_status: { type: integer, nullable: true }
        latency_ms:
          type: integer
          nullable: true
          description: Durée wall-clock en ms. `null` sur les lignes antérieures.
        attempt_count:
          type: integer
          description: |
            N° de tentative (1-3, retry 0s/30s/5min côté dispatcher).
            Plusieurs rows peuvent partager le même event_id si retry.
        status:
          type: string
          enum: [delivered, failed, pending]
          description: Computed depuis `delivered_at`/`error`.
        next_retry_at:
          type: string
          format: date-time
          nullable: true
        created_at: { type: string, format: date-time }
        last_attempted_at: { type: string, format: date-time }
        error_message:
          type: string
          nullable: true
          description: Extrait tronqué à 200 chars si échec.
    PaginatedDeliveries:
      type: object
      description: Enveloppe paginée `GET /v1/me/webhooks/deliveries`.
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/DeliveryOut" }
        next_cursor:
          type: string
          nullable: true
        has_more: { type: boolean }
    UsageOut:
      type: object
      description: |
        Réponse `GET /v1/me/usage`. Fenêtre semi-open
        `[period_start, period_end)` sur la date de création de l'appel. Période
        sans appel = 200 avec zéros (pas 404).
      required: [period, period_start, period_end, total_calls, total_seconds, breakdown]
      additionalProperties: false
      properties:
        period:
          type: string
          pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          example: "2026-04"
        period_start: { type: string, format: date-time }
        period_end:
          type: string
          format: date-time
          description: Premier instant du mois suivant (exclu).
        total_calls: { type: integer }
        total_seconds:
          type: integer
          description: |
            Somme `billable_s` de TOUS les calls du mois (incl.
            in-flight). Pour facturation réelle voir
            `breakdown.completed_seconds`.
        breakdown:
          $ref: "#/components/schemas/UsageStatusBreakdown"
    Balance:
      type: object
      properties:
        balance_seconds: { type: integer }
        balance_human: { type: string, example: 10h00m }
        as_of: { type: string, format: date-time }
    Transaction:
      type: object
      properties:
        id: { type: integer }
        type: { type: string, enum: [topup, debit_call, refund, adjustment] }
        seconds: { type: integer, description: "Positif (topup/refund) ou négatif (debit)." }
        reference: { type: string, nullable: true }
        reason: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
    TransactionList:
      allOf:
        - $ref: "#/components/schemas/Page"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Transaction" }
    UsagePeriod:
      type: object
      properties:
        period: { type: string, example: "2026-04" }
        calls_total: { type: integer }
        calls_by_result:
          type: object
          additionalProperties: { type: integer }
        billable_seconds: { type: integer }
        cost_usd: { type: string }
    ApiKey:
      type: object
      description: |
        Legacy — conservé pour compat. Le contrat public utilise
        `ApiKeyOut` (retourné par `GET /v1/me/api-keys`).
      properties:
        id: { type: string }
        prefix: { type: string, example: yk_abc123... }
        label: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
    ApiKeyList:
      type: object
      description: Legacy — utiliser `ApiKeyListOut`.
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ApiKey" }
    ApiKeyCreated:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: Legacy — utiliser `ApiKeyCreatedOut.secret_key`.
    ApiKeyCreateIn:
      type: object
      description: Body `POST /v1/me/api-keys`. Tout champ non déclaré est rejeté.
      additionalProperties: false
      properties:
        label:
          type: string
          nullable: true
          maxLength: 64
          description: |
            Description libre pour identifier la clé côté UI/logs
            (ex: `ci-prod`, `shopify-staging`). Optionnel.
    ApiKeyOut:
      type: object
      description: |
        Shape commune GET / DELETE clé API. **Anti-BOPLA** :
        jamais de `key_hash`, jamais de `secret_key` — seul le
        `key_prefix` (12 chars) est exposé.
      required: [id, key_prefix, created_at, status]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        key_prefix:
          type: string
          description: |
            12 premiers chars de la clé en clair. Suffisant pour
            identifier côté UI, insuffisant pour forger.
          example: "yk_Xa7bC3dE5f"
        label:
          type: string
          nullable: true
          maxLength: 64
        created_at: { type: string, format: date-time }
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Dernière utilisation de la clé (posée par
            `touch_used()` à chaque auth réussie).
        last_used_ip:
          type: string
          nullable: true
          description: |
            Dernière IP client vue au moment de l'auth (format INET :
            IPv4 ou IPv6).
        revoked_at:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum: [active, revoked]
          description: |
            Computed côté serveur depuis `revoked_at` : `active` si
            NULL, `revoked` sinon.
    ApiKeyListOut:
      type: object
      description: Réponse `GET /v1/me/api-keys`.
      required: [data]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ApiKeyOut" }
    ApiKeyCreatedOut:
      type: object
      description: |
        Réponse `POST /v1/me/api-keys`. `secret_key` **visible
        une seule fois** — à stocker immédiatement côté reseller.
      required: [id, key_prefix, created_at, secret_key]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        key_prefix: { type: string, example: "yk_Xa7bC3dE5f" }
        label: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        secret_key:
          type: string
          description: |
            Clé en clair (`yk_<40 chars base62>`) — **visible 1 seule
            fois**. Aucune autre route ne la re-expose. Perte =
            révocation + nouvelle création obligatoire.
    ApiKeyEventOut:
      type: object
      description: |
        Un event du journal d'audit d'une clé API. `api_key_id`
        et `reseller_id` ne sont **pas** exposés (redondants avec
        l'URL et le scope tenant).
      required: [id, event_type, created_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        event_type:
          type: string
          enum: [created, revoked, rate_limited]
          description: |
            Type d'événement. La liste peut être étendue dans une future
            version (ex: `used_from_new_ip`) — côté reseller, traiter
            un `event_type` inconnu comme information sans bloquer.
        ip:
          type: string
          nullable: true
          description: IP source (INET). Null si non capturée.
        user_agent: { type: string, nullable: true }
        metadata:
          type: object
          nullable: true
          description: |
            Metadata libre selon le type d'event :
            - `created` → `{"label": "<label>"}` si un label a été fourni.
            - `rate_limited` → `{"path": "/v1/...", "limit": "<N> per <unit>"}`.
            - `revoked` → `null`.
        created_at: { type: string, format: date-time }
    ApiKeyEventListOut:
      type: object
      description: |
        Enveloppe `GET /v1/me/api-keys/{id}/events` — curseur
        forward-only HMAC, TTL 24 h.
      required: [data, next_cursor, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ApiKeyEventOut" }
        next_cursor:
          type: string
          nullable: true
          description: Curseur opaque à passer pour la page suivante.
        has_more: { type: boolean }
    WebhookCreateInput:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          description: |
            URL HTTPS publique où Yasmine enverra les events. Doit accepter
            `POST application/json` et répondre `2xx` sous 10 s.
          example: "https://monshop.com/yasmine-webhooks"
    WebhookCreated:
      type: object
      required: [url, secret, created_at]
      additionalProperties: false
      properties:
        url: { type: string, format: uri }
        secret:
          type: string
          description: |
            Secret HMAC-SHA256 base64url (256 bits d'entropie). **Affiché
            une seule fois** — à stocker immédiatement côté reseller.
            Format `whsec_<43 chars>`. Les secrets legacy
            antérieurs (sans préfixe) restent fonctionnels — la vérification
            HMAC est prefix-agnostique.
          example: "whsec_tAunISmhee9BvK-PpHE5GfXZp_0WnMJwZB6SRbZUpng"
        created_at: { type: string, format: date-time }
    WebhookGet:
      type: object
      required: [url, secret_prefix, active, created_at]
      additionalProperties: false
      properties:
        url: { type: string, format: uri }
        secret_prefix:
          type: string
          description: |
            12 premiers caractères du secret + `…`. Le secret complet
            n'est jamais renvoyé. Pour les nouveaux secrets format
            `whsec_`, affiche `whsec_xxxxxx…` (6 chars utiles après le
            préfixe repère). Pour les secrets legacy, 12 chars bruts.
          example: "whsec_tAunIS…"
        active: { type: boolean }
        created_at: { type: string, format: date-time }
    WebhookSecretRotated:
      type: object
      description: |
        Réponse `POST /v1/me/webhooks/rotate-secret`. Pattern
        Stripe — `secret` visible une seule fois.
      required: [secret, secret_prefix, rotated_at]
      additionalProperties: false
      properties:
        secret:
          type: string
          description: |
            Nouveau secret HMAC `whsec_<43 chars>`. **Affiché une seule
            fois**. L'ancien secret ne signe plus aucun event dès le
            retour 200.
          example: "whsec_tAunISmhee9BvK-PpHE5GfXZp_0WnMJwZB6SRbZUpng"
        secret_prefix:
          type: string
          description: 12 premiers chars + `…`, écho permanent via `GET /v1/me/webhooks`.
          example: "whsec_tAunIS…"
        rotated_at: { type: string, format: date-time }
    WebhookUrlRejectedProblem:
      allOf:
        - $ref: "#/components/schemas/Problem"
        - type: object
          properties:
            reason:
              type: string
              enum:
                - invalid_url
                - scheme_not_allowed
                - localhost_rejected
                - dns_resolution_failed
                - private_ip_rejected
    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        event_id: { type: string }
        event_type: { type: string }
        status: { type: string, enum: [pending, delivered, failed, dropped] }
        attempts: { type: integer }
        last_http_code: { type: integer, nullable: true }
        last_error: { type: string, nullable: true }
        next_attempt_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    WebhookDeliveryList:
      allOf:
        - $ref: "#/components/schemas/Page"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/WebhookDelivery" }
