# LibrePay — spécification OpenAPI de l'API publique.
#
# CETTE SPEC DÉCRIT CE QUI EXISTE, PAS LA CIBLE.
#
# `docs/cadrage/06-api-publique.md` décrit l'API VISÉE. Ce fichier ne décrit que
# ce qui répond. Une spec qui décrirait la cible ferait écrire du code contre des
# routes inexistantes — c'est la façon la plus sûre de perdre la confiance d'un
# intégrateur. L'inverse coûte aussi cher : cet en-tête a affirmé pendant des
# semaines que payouts, lots, bénéficiaires et règlements n'existaient pas, alors
# qu'ils sont décrits quelques centaines de lignes plus bas.
#
# Ce qui est ici est appelable aujourd'hui, et vérifié par
# `qa/lot4_openapi_sdk.py` : chaque chemin décrit est éprouvé contre la sandbox
# réelle, et chaque code d'erreur documenté est provoqué.

openapi: 3.1.0

info:
  title: LibrePay — API publique
  version: '2026-08-25'
  description: |
    Encaissement mobile money en Côte d'Ivoire, par une intégration unique.

    **Conventions**

    - Montants : **entier en unité mineure**. Le franc CFA n'a pas de
      subdivision : `10000` vaut 10 000 F. Un montant en chaîne ou à décimales
      est refusé — deviner l'intention d'un montant est le début des ennuis.
    - Idempotence : `Idempotency-Key` est **obligatoire** sur tout POST qui
      déplace de l'argent. Rejouer la même clé avec le même corps rend la
      **réponse d'origine**, sans second mouvement.
    - Traçabilité : toute réponse porte `LibrePay-Request-Id`. C'est
      l'identifiant à citer au support, et celui qui apparaît dans le journal
      des requêtes de votre console.
    - Test et live : **la clé détermine l'environnement**. Aucun paramètre de
      mode, et aucune donnée ne traverse la frontière.

    **Le décaissement fait SORTIR de l'argent** et obéit donc à deux règles que
    l'encaissement n'a pas : il exige une clé de **portée `payout`**, distincte
    de celle qui encaisse, et il est soumis à des **plafonds** que le marchand
    règle dans sa console. Au-delà d'un plafond, l'appel n'est pas refusé — il
    rend `202` et attend une validation humaine.

    **Ce qui n'existe pas encore** : bénéficiaires en CRUD, règlements,
    pagination. Ne pas coder contre ces routes : elles ne répondent pas.

servers:
  - url: https://api.librepay.avasoftware.net/functions/v1
    description: |
      Production. Vérifié joignable le 2026-09-08.

      La note qui figurait ici disait que ce domaine « n'est pas encore monté »
      et documentait donc l'URL brute de l'hébergeur. C'était juste au moment où
      elle a été écrite ; ça ne l'était plus, et la documentation publique
      envoyait les intégrateurs vers la SANDBOX en la présentant comme l'API.
      Une note d'attente doit être vérifiée avant chaque publication : elle se
      périme sans prévenir et personne ne la relit.
  - url: https://qkmvexkljdcfarulydao.supabase.co/functions/v1
    description: |
      Sandbox. Aucun argent réel n'y circule. C'est l'adresse à employer pour
      intégrer et éprouver son code avant de passer en production.

security:
  - cleSecrete: []

tags:
  - name: Paiements
    description: Encaissements.
  - name: Décaissements
    description: |
      Envoi d'argent vers un bénéficiaire. Exige une clé de portée `payout`.
  - name: Checkout
    description: |
      Page de paiement hébergée : vous créez une session, vous envoyez son URL
      au payeur, nous nous chargeons du reste.
  - name: Lots de décaissement
    description: |
      Décaissement de masse. Un lot naît en brouillon, s'exécute par un appel
      distinct, et une ligne fautive n'annule pas les autres.
  - name: Webhooks
    description: |
      Les notifications que nous vous envoyons quand l'état d'une opération
      change. **C'est le seul moyen fiable d'être averti** : interroger l'API en
      boucle coûte cher aux deux parties et arrive toujours en retard.
  - name: Bénéficiaires
    description: |
      Le carnet des destinations vers lesquelles votre argent peut sortir.
      **En lecture seule par API** : une destination s'enregistre depuis votre
      console. Voir la description de la route pour la raison.
  - name: Opérateurs
    description: |
      Ce que la plateforme sert AUJOURD'HUI : quels opérateurs, pour quel
      service, entre quels montants et à quel tarif. **Sans clé** — c'est
      l'information que la vitrine publie déjà.
  - name: Tarifs
    description: |
      VOTRE tarif : celui que LibrePay prélève sur les opérations de votre
      clé, barème négocié compris. Le barème PUBLIC se lit sur `/v1-operators`.
  - name: Règlements
    description: |
      Se faire verser ce qu'on vous doit, vers une destination de votre carnet.
      À ne pas confondre avec un décaissement : un règlement vous rend VOTRE
      argent depuis votre disponible, et il n'est pas facturé.

paths:
  /v1-payments:
    post:
      tags: [Paiements]
      operationId: creerPaiement
      summary: Créer un encaissement
      description: |
        Crée une intention d'encaissement. **La réponse ne dit pas que l'argent
        est arrivé** : le paiement naît `created`, passe `pending` pendant que le
        connecteur travaille, et n'est `succeeded` qu'une fois la preuve reçue de
        l'opérateur. Écoutez le webhook, ou interrogez `GET`.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `10000` = 10 000 F.
                  example: 10000
                currency:
                  type: string
                  pattern: '^[A-Za-z]{3}$'
                  default: XOF
                operator:
                  type: string
                  description: Opérateur visé, p. ex. `orange_ci`, `mtn_ci`.
                  example: orange_ci
                counterparty:
                  type: object
                  description: Le payeur.
                  properties:
                    msisdn:
                      type: string
                      example: '0779149021'
                merchant_reference:
                  type: string
                  description: |
                    Votre référence. **Unique par marchand** : une seconde
                    transaction portant la même valeur est refusée en 409. C'est
                    une protection contre le double encaissement, pas une gêne.
                description:
                  type: string
                metadata:
                  type: object
                  additionalProperties: true
                simulate:
                  type: string
                  enum: [succeed]
                  description: |
                    **Sandbox uniquement** : force l'issue sans passer par le
                    connecteur. Ignoré en live.
            examples:
              encaissement:
                value:
                  amount: 10000
                  currency: XOF
                  operator: orange_ci
                  counterparty: { msisdn: '0779149021' }
                  merchant_reference: CMD-2026-00412
      responses:
        '201':
          description: |
            Paiement créé.

            **Un rejeu d'idempotence rend AUSSI 201.** Même clé, même corps : la
            réponse d'origine est restituée telle quelle, statut compris, et
            aucun second mouvement n'a lieu. N'en déduisez donc pas qu'un
            paiement vient d'être créé — `201` signifie « voici le paiement qui
            correspond à cette clé », pas « un de plus ». Fiez-vous à l'`id`.
          headers:
            LibrePay-Request-Id: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Paiement' }
        '400':
          description: |
            `idempotency_key_required` — l'en-tête est obligatoire.
            `invalid_body` — corps JSON illisible.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: |
            `authentication_required` — aucune clé fournie.
            `invalid_api_key` — clé inconnue, révoquée ou malformée. Les trois
            cas rendent le **même** code : les distinguer aiderait à deviner des
            clés valides.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          description: |
            Deux causes, et le code les distingue.

            **`rate_limit_exceeded`** — vous appelez trop vite. Voir la réponse
            partagée plus bas : `Retry-After` dit quand revenir.

            **`too_many_attempts`** — trop de tentatives récentes pour LE MÊME
            PAYEUR. Ce n'est pas une limite sur vous, c'est une protection pour
            lui : une rafale de tentatives sur un même numéro est la signature
            du test de numéros volés. Un client qui se trompe de code recommence
            deux ou trois fois, pas dix. Le message est volontairement identique
            pour tout le monde — le préciser apprendrait à qui teste des numéros
            la cadence à tenir pour ne plus être vu.
          # LES EN-TÊTES SONT REPRIS ICI, et ce n'est pas une redondance : cette
          # route a une description propre (deux causes possibles), donc elle ne
          # peut plus pointer sur la réponse partagée. Les omettre aurait retiré
          # `Retry-After` de la seule route où l'on encaisse.
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`merchant_inactive` — le compte ne peut pas encaisser.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `idempotency_in_progress` — une requête identique est en cours.
            `duplicate_reference` — ce `merchant_reference` désigne déjà une
            autre transaction.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `payment_creation_failed` — une panne de notre côté, jamais une faute de votre
            requête. **Réessayez avec la MÊME clé d'idempotence** : c'est ce qui
            garantit qu'une opération ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, ou `idempotency_key_reused`
            (même clé, corps différent).

            **`amount_above_limit`** — le montant dépasse le plafond PAR
            OPÉRATION de votre compte. Ce seuil attrape la faute de frappe
            autant que la fraude : un montant très au-dessus de vos paiements
            habituels mérite d'être regardé avant d'être accepté. Si votre
            activité le justifie, écrivez-nous : il se relève.
            **`amount_too_small` / `amount_too_large`** — le montant sort des
            limites que nous appliquons sur ce couple service/opérateur. Elles
            sont publiques : `GET /v1-operators` les rend (`min_amount`,
            `max_amount`), et notre page Tarifs les affiche. Lisez-les pour
            borner votre formulaire plutôt que de laisser découvrir le refus
            après la saisie.

            **`monthly_limit_reached`** — votre compte a atteint le plafond
            d'ENCAISSEMENT de son palier, compté sur trente jours glissants.
            Il ne s'agit pas du montant de cette requête : elle serait passée
            hier. La réponse porte le plafond et ce qui est déjà encaissé.

            Ce plafond monte avec le palier : déposez une pièce de vérification
            depuis votre console. Il est distinct de celui des sorties de fonds
            — les deux enveloppes sont comptées séparément, et encaisser
            n'entame pas ce que vous pouvez retirer.

            Les remboursements rendent de l'enveloppe. Le mode test n'est
            jamais plafonné : vos essais ne consomment rien.

            ⚠ À ne pas confondre avec `amount_above_limit` ci-dessus : celui-là
            parle du plafond PAR OPÉRATION de votre compte ; `monthly_limit_reached`
            parle d'un CUMUL sur trente jours. Et ni l'un ni l'autre ne se
            confond avec `amount_too_small` / `amount_too_large`, qui parlent de
            ce que la plateforme traite, pour tout le monde.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Paiements]
      operationId: lirePaiement
      summary: Lire un paiement
      description: |
        L'identifiant se place en fin de chemin :
        `…/v1-payments/txn_XXXXXXXX`.

        Un paiement appartenant à un autre marchand rend **404**, jamais 403 :
        répondre 403 confirmerait son existence.
      parameters:
        - name: id
          in: query
          required: false
          schema: { type: string }
          description: Fourni en fin de CHEMIN, pas en paramètre. Voir la description.
      responses:
        '200':
          description: Le paiement.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Paiement' }
        '422':
          description: '`invalid_id` — l''identifiant ne ressemble pas à un `txn_…`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — inconnu, ou appartenant à un autre marchand.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payouts:
    post:
      tags: [Décaissements]
      operationId: creerDecaissement
      summary: Décaisser vers un bénéficiaire
      description: |
        **La seule route de cette API qui fait sortir de l'argent** vers une
        destination que vous choisissez. Elle obéit donc à deux règles propres.

        **1. La portée de la clé.** Une clé `collect` reçoit `403
        insufficient_scope`. Créez une clé dédiée au décaissement depuis votre
        console : la clé de votre site n'a alors aucun pouvoir d'envoi.

        **2. Les plafonds.** Par opération et par 24 heures glissantes, réglables
        dans votre console. Au-delà, l'appel rend **202** et non une erreur : la
        demande part en validation humaine, et rien ne bouge tant qu'un
        responsable n'a pas tranché depuis la console. Le plafond sur 24 h compte
        aussi les demandes en attente — sinon il suffirait d'en ouvrir cent.

        Les plafonds **bornent** ce qu'une clé compromise peut envoyer ; ils ne
        vérifient pas la destination. Gardez-les au plus près de votre usage réel.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `50000` = 50 000 F.
                currency: { type: string, default: XOF }
                beneficiary_id:
                  type: string
                  description: |
                    Un bénéficiaire de votre carnet. **Ou** `beneficiary` en
                    ligne — l'un des deux est requis.
                beneficiary:
                  type: object
                  description: |
                    Destination fournie en ligne — **mobile money ou virement
                    bancaire**. Elle est enregistrée dans votre carnet : c'est
                    ce qui vous permet de relire ensuite, dans votre console, où
                    votre argent est parti.

                    Pour du mobile money : `msisdn`, `operator`, `name`.
                    Pour un virement : `iban`, `banque`, `operator`, `name`.

                    ⚠ **`operator` désigne le CANAL, pas la banque
                    destinataire.** `bda_ci` est la voie par laquelle l'argent
                    part ; la banque qui reçoit se lit dans l'IBAN.

                    ⚠ **Une destination déjà connue est réutilisée, pas
                    recréée.** Le mobile se retrouve par le couple
                    (numéro, opérateur) — un compte Wave est adossé à un numéro
                    Orange ; le bancaire se retrouve par le seul **IBAN**, un
                    compte étant une destination et non un couple.
                  properties:
                    msisdn:
                      type: string
                      description: Mobile money — 10 chiffres, ou format international `+225…`.
                      example: '0700000021'
                    iban:
                      type: string
                      description: |
                        Virement — nous n'en contrôlons que la FORME. La banque
                        seule sait si le compte existe.
                      example: CI93 CI201 01001 127586883703 85
                    banque:
                      type: string
                      description: Virement — l'établissement qui reçoit.
                      example: BANQUE D ABIDJAN
                    operator: { type: string, example: orange_ci }
                    name:
                      type: string
                      description: Le titulaire. Requis — c'est ce que vous relirez.
                      example: Koffi Y.
                reference:
                  type: string
                  description: Votre référence, reprise telle quelle.
                  example: SAL-08-2026-017
            examples:
              carnet:
                summary: Vers un bénéficiaire déjà enregistré
                value: { amount: 50000, beneficiary_id: '…', reference: SAL-08-2026-017 }
              enLigne:
                summary: Destination mobile fournie à l'appel
                value:
                  amount: 50000
                  beneficiary: { msisdn: '0700000021', operator: orange_ci, name: Koffi Y. }
              enLigneBancaire:
                summary: Virement bancaire fourni à l'appel
                value:
                  amount: 50000
                  beneficiary:
                    iban: CI93 CI201 01001 127586883703 85
                    banque: BANQUE D ABIDJAN
                    operator: bda_ci
                    name: KOUASSI SARL
      responses:
        '201':
          description: |
            Décaissement enregistré. **Comme pour un encaissement, un rejeu
            d'idempotence rend AUSSI 201** avec la réponse d'origine : `201` veut
            dire « voici le décaissement de cette clé », pas « un de plus ».
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decaissement' }
        '202':
          description: |
            **Plafond dépassé : la demande attend une validation humaine.** Rien
            n'est parti. `reason` dit quel plafond (`plafond_operation` ou
            `plafond_24h`) et `limit` sa valeur. Ne traitez pas `202` comme un
            succès : suivez la demande par `GET /v1-payouts/por_…`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DemandeDecaissement' }
        '400':
          description: '`idempotency_key_required`, `invalid_body`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: |
            **`insufficient_scope`** — cette clé n'a pas la portée `payout`.
            C'est le cas d'une clé d'encaissement : elle ne peut pas décaisser,
            et c'est délibéré.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — bénéficiaire inconnu ou archivé.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `insufficient_funds` — votre solde de **transfert** ne couvre pas ce
            décaissement. Ce n'est pas votre solde de collecte : approvisionnez
            le transfert, ou balayez depuis la collecte.
            `idempotency_in_progress` — requête identique en cours.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `payout_failed` — une panne de notre côté, jamais une faute de votre
            requête. **Réessayez avec la MÊME clé d'idempotence** : c'est ce qui
            garantit qu'une opération ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, `invalid_beneficiary`,
            `idempotency_key_reused`.

            **`amount_too_small` / `amount_too_large`** — le montant sort des
            limites que nous appliquons sur ce couple service/opérateur, celles
            que `GET /v1-operators` rend pour `payout`. L'opérateur est celui de
            la destination : une même somme peut passer chez l'un et pas chez
            l'autre.

            ⚠ Un dépassement du plafond de VOTRE COMPTE ne se refuse pas, lui :
            il met la demande **en attente de validation** et rend `202`.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Décaissements]
      operationId: lireDecaissement
      summary: Lire un décaissement ou une demande
      description: |
        L'identifiant se place en fin de chemin. Deux formes :
        `…/v1-payouts/txn_…` pour un décaissement, `…/v1-payouts/por_…` pour une
        demande en attente de validation.

        Une ressource appartenant à un autre marchand rend **404**, jamais 403.
      responses:
        '200':
          description: Le décaissement, ou la demande.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Decaissement'
                  - $ref: '#/components/schemas/DemandeDecaissement'
        '404':
          description: '`resource_not_found`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — ni `txn_…` ni `por_…`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches:
    post:
      tags: [Lots de décaissement]
      operationId: creerLotDecaissement
      summary: Créer un lot de décaissements
      description: |
        **Un lot n'est pas « n appels unitaires ».** Trois différences :

        - **le plafond porte sur le TOTAL** — sinon cent lignes sous le plafond
          unitaire passeraient pour dix fois le plafond journalier ;
        - **une ligne fautive n'annule pas les autres** à l'exécution ;
        - **l'idempotence porte sur le lot** — rejouer rend le même lot.

        **Le lot ne s'exécute PAS à sa création.** Il naît `brouillon` ;
        l'exécution est un appel distinct (`/execute`). Créer et payer d'un seul
        geste rendrait impossible de relire ce qu'on s'apprête à envoyer — or
        c'est exactement ce qu'on veut relire quand il y a cent lignes.

        **À la création, tout est validé ou rien n'est écrit.** Une ligne
        invalide refuse le lot entier, en donnant son **rang** : un lot
        partiellement retenu obligerait à comparer la source ligne à ligne,
        c'est-à-dire le travail qu'un lot évite.

        Au plus **500 lignes**. Au-delà, refus franc : un lot à moitié exécuté
        est le pire des états.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lines]
              properties:
                reference: { type: string, example: PAIE-2026-08 }
                currency: { type: string, default: XOF }
                lines:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [amount]
                    properties:
                      amount: { type: integer, minimum: 1, example: 30000 }
                      beneficiary_id: { type: string }
                      beneficiary:
                        type: object
                        properties:
                          msisdn: { type: string, example: '0700000021' }
                          operator: { type: string, example: orange_ci }
                          name: { type: string, example: Koffi Y. }
                      reference: { type: string }
      responses:
        '201':
          description: |
            Lot créé. `status` vaut `brouillon` — **rien n'est parti** — ou
            `en_attente_validation` si le total dépasse le plafond sur 24 h.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '400':
          description: '`idempotency_key_required`, `invalid_body`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`insufficient_scope` — clé sans la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: '`idempotency_in_progress`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_lines` — tableau absent ou vide.
            `too_many_lines` — plus de 500 lignes.
            `invalid_amount`, `invalid_beneficiary`,
            `amount_too_small` / `amount_too_large` — **le message porte le rang
            de la ligne fautive**.
            `idempotency_key_reused`.

            Les bornes de montant sont éprouvées **à la création du lot**, pas à
            son exécution : un lot validé par un humain ne doit pas se voir
            refuser une ligne au moment de payer. Lisez-les sur
            `GET /v1-operators` avant de composer le fichier.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Lots de décaissement]
      operationId: lireLotDecaissement
      summary: Lire un lot et ses lignes
      description: |
        `…/v1-payout-batches/pob_…`. Rend le lot **et le détail de ses lignes**,
        avec le statut et le motif d'échec de chacune.
      responses:
        '200':
          description: Le lot et ses lignes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '404':
          description: '`resource_not_found`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches/{id}/execute:
    post:
      tags: [Lots de décaissement]
      operationId: executerLotDecaissement
      summary: Exécuter un lot en brouillon
      description: |
        **C'est cet appel qui envoie l'argent.** La création n'envoie rien : un
        lot naît `brouillon` précisément pour qu'on puisse le relire avant.

        La description de la création citait cette route sans que la
        spécification la décrive — corrigé le 2026-09-07.

        **Une ligne fautive n'annule pas les autres.** Le lot revient avec le
        détail de chaque ligne et son motif d'échec ; on reprend les rangs en
        échec, pas le lot entier.

        Exige la portée `payout`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: pob_7cP2rQ1s }
      responses:
        '200':
          description: Le lot exécuté, avec le détail de ses lignes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '404':
          description: '`resource_not_found` — lot inconnu, ou d''un autre mode.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `batch_pending_approval` — le lot dépasse un plafond et attend une
            validation dans la console ; ou `batch_not_executable` — un lot déjà
            exécuté, annulé ou terminé ne se rejoue pas.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches/{id}/cancel:
    post:
      tags: [Lots de décaissement]
      operationId: annulerLotDecaissement
      summary: Annuler un lot avant exécution
      description: |
        N'annule que ce qui **n'a rien envoyé** : un lot `brouillon` ou en
        attente de validation. Un lot en cours a des lignes parties, et
        « annuler » laisserait croire qu'elles sont rappelées — elles ne le sont
        pas.

        Exige la portée `payout`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: pob_7cP2rQ1s }
      responses:
        '200':
          description: Le lot, désormais `annule`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '409':
          description: |
            `batch_not_cancellable` — lot inconnu, ou déjà exécuté. Les lignes
            parties ne se rappellent pas.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-checkout:
    post:
      tags: [Checkout]
      operationId: creerSessionCheckout
      summary: Créer une session de paiement hébergée
      description: |
        **Quand la préférer à `/v1-payments`.** Vous n'avez alors ni numéro à
        collecter, ni écran à construire, ni opérateur à choisir : vous envoyez
        une URL. La page est tenue à jour par nos soins, y compris quand un
        opérateur change son parcours.

        La session naît `open` et **expire**. `expires_at` le dit ; passé ce
        délai, le lien affiche « demande expirée » plutôt qu'un formulaire mort.

        Exige la portée `collect` — c'est un encaissement.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `10000` = 10 000 F.
                currency: { type: string, default: XOF }
                description:
                  type: string
                  description: |
                    Ce que le PAYEUR lira sur la page. Mettez-y votre référence
                    de commande : c'est ce qui lui permet de reconnaître ce
                    qu'il paie.
                  example: Commande #4821
                return_url:
                  type: string
                  format: uri
                  description: |
                    Où renvoyer le payeur après un paiement réussi. **HTTPS
                    obligatoire** — un retour en clair exposerait la référence
                    de commande dans l'URL.
      responses:
        '201':
          description: |
            Session créée. **`url` est ce que vous transmettez au payeur** — ne
            reconstruisez pas ce lien vous-même, le jeton n'est pas devinable et
            c'est ce qui protège la session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionCheckout' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `collect`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, `invalid_return_url`
            (l'URL de retour n'est pas en HTTPS), ou `invalid_reference`
            (votre `reference` fait plus de 120 caractères).

            Sur un lien à montant libre, le payeur peut aussi recevoir
            `amount_too_small` ou `amount_too_large` — les bornes du lien sont
            rendues avec la session, affichez-les avant la saisie plutôt que de
            laisser découvrir un plancher après coup.

            Au moment où le payeur valide : `invalid_msisdn` (numéro ivoirien à
            dix chiffres attendu) et `invalid_operator` (opérateur hors de ceux
            que la session propose).

            **`merchant_limit_reached`** — le marchand a atteint le plafond
            d'encaissement de son palier. Le paiement est refusé alors que le
            PAYEUR n'y est pour rien : aucun montant ne lui est prélevé, et le
            message ne lui montre ni le plafond ni le cumul du marchand — ce
            sont les affaires de celui-ci.

            Si vous intégrez cette page, n'invitez pas le payeur à réessayer :
            rien ne changera tant que le marchand n'a pas franchi un palier ou
            que la fenêtre de trente jours n'a pas glissé. Invitez-le à
            contacter le commerçant.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            La session ne peut plus recevoir de paiement :
            `session_expired` (le délai est passé), `session_closed` (elle est
            déjà payée ou fermée), `lien_indisponible` (le lien réutilisable a
            été fermé ou a expiré).

            Ces trois-là ne se réessaient pas : créez une nouvelle session.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `checkout_creation_failed`, `payment_failed` ou `bornes_illisibles` —
            une panne de notre côté, jamais une faute de votre requête. Réessayez
            avec la MÊME clé d'idempotence : c'est ce qui garantit qu'un
            encaissement ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-checkout/{id}/cancel:
    post:
      tags: [Checkout]
      operationId: annulerSessionCheckout
      summary: Annuler une session de paiement encore ouverte
      description: |
        **Quand l'appeler.** La commande (ou la facture) derrière la session a
        été annulée : sans cet appel, le lien reste payable jusqu'à
        `expires_at`, et l'argent arriverait pour une vente qui n'existe plus.

        N'annule qu'une session **`open`**. Une session `processing` a un débit
        en route chez l'opérateur — « annuler » ne le rappellerait pas, et
        laisserait croire le contraire ; attendez son issue. Une session conclue
        (`succeeded`, `failed`, `expired`) ne change plus.

        **Rejouable** : annuler une session déjà `cancelled` rend la session,
        sans erreur — une requête renvoyée après une coupure ne se lit pas comme
        un refus.

        Le payeur qui ouvre ensuite le lien lit « demande annulée » ; s'il avait
        la page ouverte, sa validation est refusée (`session_closed`).

        Exige la portée `collect`. La clé désigne le mode : une clé de test
        n'annule pas une session live.
      parameters:
        - name: id
          in: path
          required: true
          description: L'identifiant `cs_…` rendu à la création (pas le jeton de l'URL).
          schema: { type: string, example: cs_JBpm7AnJZvBBRHcloKKIfg }
      responses:
        '200':
          description: La session, désormais `cancelled`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionCheckout' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `collect`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: |
            `resource_not_found` — aucune session de ce marchand, dans ce mode,
            ne porte cet identifiant.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `checkout_not_cancellable` — la session n'est plus ouverte : un
            paiement est en route (`processing`) ou elle est conclue. Le message
            dit son état.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `cs_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '500':
          description: |
            `checkout_cancel_failed` — une panne de notre côté ; réessayez :
            l'annulation est rejouable.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-operators:
    get:
      tags: [Opérateurs]
      operationId: listerOperateurs
      # PUBLIQUE, ET C'EST DÉLIBÉRÉ. Un tableau vide annule la sécurité globale
      # pour cette route seule. Exiger une clé pour lire la liste des moyens de
      # paiement ne protégerait rien — la page vitrine l'affiche à tout
      # visiteur — et obligerait un site statique à en embarquer une.
      security: []
      summary: Les opérateurs servis, leurs montants et leur tarif
      description: |
        **À quoi ça sert.** Construire votre écran de paiement sans recopier
        une liste d'opérateurs qui vieillira. Proposer un opérateur qu'aucun
        canal ne joint fait échouer le paiement APRÈS que le payeur a saisi son
        numéro — au moment le plus coûteux — et l'omettre alors qu'il
        fonctionne fait perdre la vente sans que personne ne le sache.

        La réponse ne DÉCLARE rien : elle dérive du routage actif et de ce que
        chaque canal sert aujourd'hui. Un opérateur qui ferme disparaît d'ici
        dans la minute.

        ### Les montants
        `min_amount` et `max_amount` sont les bornes de l'OPÉRATION, en unité
        mineure. Elles valent l'intersection de ce que LibrePay accepte et de
        ce que le menu de l'opérateur accepte. **Ce ne sont pas vos plafonds
        de compte** : ceux-là dépendent de votre niveau de vérification et se
        lisent dans votre console.

        ### Le tarif
        `pricing` est le barème PUBLIC de la plateforme, celui qu'affiche notre
        page Tarifs. Si vous avez négocié un barème, il n'apparaît pas ici —
        cette route ne sait pas qui l'interroge. `pricing` est **absent** quand
        aucune règle ne s'applique ; ne le lisez jamais comme « gratuit ».

        ### Ce qu'elle ne fait jamais
        Rendre une liste vide sur incident. Une panne répond **503**, pour que
        vous puissiez garder votre dernière réponse connue au lieu de conclure
        « aucun opérateur ».
      responses:
        '200':
          description: |
            Les opérateurs servis, groupés par service : `collect` pour
            encaisser, `payout` pour verser.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListeOperateurs' }
        '503':
          description: |
            `temporarily_unavailable` — la liste n'a pas pu être établie.
            **Ce n'est pas « aucun opérateur »** : gardez votre dernière
            réponse connue et réessayez.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-pricing:
    get:
      tags: [Tarifs]
      operationId: lireTarif
      summary: Le tarif qui s'applique à VOS opérations
      description: |
        **À quoi ça sert.** Connaître ce que chaque encaissement et chaque
        décaissement vous coûtera, sans recopier une grille reçue par courriel
        — une grille recopiée diverge au premier changement sans que rien ne
        le dise.

        **C'est le tarif qui sera prélevé**, pas une estimation : il est résolu
        par la règle même qui fige la commission d'une transaction — votre
        barème négocié d'abord, puis celui de l'opérateur, puis la règle la
        plus récente. `/v1-operators`, lui, rend le barème PUBLIC et ne sait
        pas qui l'interroge.

        **Le mode est celui de la clé** : une clé `vp_sk_test_` lit le tarif
        sandbox, une clé `vp_sk_live_` celui de production — les deux grilles
        existent et peuvent différer.

        **Toute portée est admise** : lire son propre tarif n'expose rien
        d'autrui et ne déplace pas d'argent.

        Les virements reçus sur un IBAN de collecte ne portent **aucune**
        commission : ils ne figurent pas ici.
      responses:
        '200':
          description: |
            Le tarif, par service. `standard` est la règle générale du service ;
            `operators` liste celles qui nomment un opérateur. Un tarif
            **absent** (`standard: null`) veut dire qu'aucune règle ne
            s'applique — ne le lisez jamais comme « gratuit ».
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tarification' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '500':
          description: '`server_error` — panne de notre côté ; réessayez.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-beneficiaries:
    get:
      tags: [Bénéficiaires]
      operationId: listerBeneficiaires
      summary: Lire votre carnet de destinations
      description: |
        **À quoi ça sert.** `/v1-settlements` exige `beneficiary` :
        l'identifiant d'une destination déjà enregistrée. Cette route vous le
        donne, pour que vous n'ayez pas à le relever à la main dans la console.

        Sans paramètre, rend vos destinations. Avec un UUID **en fin de
        chemin** (`…/v1-beneficiaries/<uuid>`), rend celle-là seule — une
        destination d'un autre marchand rend **404**, jamais 403.

        `?active=true` ne rend que les destinations actives. Par défaut on rend
        **tout, archivées comprises** : une destination archivée explique un
        règlement passé, et la masquer ferait chercher un fantôme.

        ### Ce qu'on ne rend jamais

        Ni l'IBAN complet, ni le numéro complet — seulement `last4`. Une réponse
        d'API se retrouve dans vos journaux, dans un cache de proxy, dans une
        capture d'écran de support. Quatre chiffres suffisent à reconnaître une
        destination qu'on a soi-même enregistrée.

        Exige la portée `payout` : le carnet dit vers où l'argent peut sortir.
      parameters:
        - name: active
          in: query
          required: false
          schema: { type: string, enum: ['true', 'false'] }
          description: Ne rendre que les destinations actives, ou que les archivées.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: |
            Votre carnet. `has_more` vous évite de compter : une page pleine ne
            signifie pas que vous avez tout vu.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Beneficiaire' }
                  total_count: { type: integer, example: 7 }
                  limit: { type: integer, example: 50 }
                  offset: { type: integer, example: 0 }
                  has_more: { type: boolean, example: false }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — cette destination n''existe pas, ou n''est pas la vôtre.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '405':
          description: |
            `method_not_allowed` — seuls `GET` et `POST` sont acceptés. Une
            destination s'archive depuis la console.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
    post:
      tags: [Bénéficiaires]
      operationId: creerBeneficiaire
      summary: Enregistrer une destination dans votre carnet
      description: |
        **À quoi ça sert.** Constituer votre carnet sans passer par la console,
        et surtout sans dépenser d'argent pour le faire.

        ### Pourquoi cette route existe depuis le 2026-09-04

        Elle n'existait pas, au motif qu'une clé compromise aurait pu s'ajouter
        une destination puis s'y faire régler — alors qu'il fallait, disait-on,
        compromettre aussi une session de console.

        **Ce second obstacle n'existait pas.** `POST /v1-payouts` accepte une
        destination *en ligne* et l'enregistre au carnet, sous la même portée
        `payout`. Une clé compromise pouvait donc déjà s'en fabriquer une ; il
        lui en coûtait un décaissement. Refuser cette route n'empêchait rien —
        elle obligeait seulement l'intégrateur honnête à faire sortir de l'argent
        pour enregistrer un numéro.

        ### Idempotente sans clé d'idempotence

        Le carnet est unique par `(marchand, mode, numéro)` parmi les
        destinations actives. Réenvoyer la même destination **ne crée pas de
        doublon** : vous recevez `200` avec la ligne existante, contre `201`
        à la création. Aucun en-tête `Idempotency-Key` n'est requis.

        **Elle ne crée que des destinations de DÉCAISSEMENT.** La destination de
        vos règlements — celle vers laquelle nous vous versons ce que nous vous
        devons — ne se crée pas par API : elle se désigne dans votre console,
        par le propriétaire du compte, avec un code de confirmation. C'est ce qui
        empêche une clé volée de rediriger vos règlements.

        Exige la portée `payout`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [msisdn, operator, name]
              properties:
                msisdn:
                  type: string
                  description: |
                    Dix chiffres (`0700000021`) ou format international
                    (`+2250700000021`). Normalisé avant enregistrement : les
                    trois écritures d'un même numéro donnent une seule ligne.
                  example: '0700000021'
                operator:
                  type: string
                  description: Le réseau de la destination.
                  example: orange_ci
                name:
                  type: string
                  minLength: 2
                  description: |
                    Ce que vous relirez dans votre console. C'est votre libellé,
                    pas une donnée vérifiée auprès de l'opérateur.
                  example: Awa Koné
            examples:
              directe:
                summary: Champs à plat
                value: { msisdn: '0700000021', operator: orange_ci, name: Awa Koné }
              enveloppee:
                summary: Sous « beneficiary », comme dans un décaissement
                value:
                  beneficiary: { msisdn: '0700000021', operator: orange_ci, name: Awa Koné }
      responses:
        '201':
          description: La destination a été créée.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Beneficiaire' }
        '200':
          description: |
            Cette destination était **déjà** à votre carnet ; elle vous est
            rendue telle quelle. Rien n'a été créé.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Beneficiaire' }
        '400':
          description: '`invalid_request` — corps JSON attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_beneficiary` — numéro illisible, `operator` absent, ou
            `name` de moins de deux caractères.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-statements:
    get:
      tags: [Relevés]
      operationId: lireReleve
      summary: Le relevé de compte sur une période
      description: |
        **Ce qui répond à « d'où vient mon solde ? ».** Ouverture, mouvements
        dans l'ordre avec un solde qui court, clôture — la forme d'un relevé
        bancaire, faite pour être versée dans une comptabilité.

        **L'invariant qui rend ce document utilisable** :
        `opening_balance + total_in − total_out = closing_balance`, et un relevé
        arrêté aujourd'hui clôture **exactement** sur votre solde disponible. Un
        relevé qui ne reproduit pas ce solde serait pire qu'aucun relevé.

        **Les quatre bornes portent sur la PÉRIODE, jamais sur la page.** Elles
        ne bougent pas quand vous paginez — n'additionnez pas `data` pour les
        recalculer, vous obtiendriez un total faux dès la deuxième page.

        **Ce n'est pas « le relevé d'un règlement ».** Un règlement est un
        retrait d'un montant choisi sur votre disponible, pas une clôture de
        période ; il figure ici comme une sortie, à sa date.

        Exige la portée `collect` — cette route LIT, elle ne déplace rien.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date, example: '2026-09-01' }
          description: Premier jour inclus, `AAAA-MM-JJ`.
        - name: to
          in: query
          required: true
          schema: { type: string, format: date, example: '2026-09-30' }
          description: |
            Dernier jour **inclus**. La journée entière compte : un relevé
            arrêté au 30 contient ce qui s'est passé le 30.
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: |
            Le relevé. Une période **sans mouvement** rend les bornes à zéro et
            `data` vide — pas une erreur : « rien n'a bougé » est une réponse.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Releve' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `collect`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_period` — `from` ou `to` absent, mal formé, ou début
            postérieur à la fin. **Un intervalle inversé est refusé**, jamais
            rendu vide : un relevé vide se lit « ce compte n'a pas bougé ».
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '500':
          description: '`statement_failed` — panne de notre côté.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-ibans:
    get:
      tags: [IBAN de collecte]
      operationId: listerIbans
      summary: Lister vos IBAN de collecte
      description: |
        Les comptes bancaires sur lesquels vous **recevez** des virements.
        Chacun porte le cumul de ce qui y est arrivé.

        ⚠ **`received_total` N'EST PAS UN SOLDE**, et le nom le dit exprès. Un
        IBAN reçoit, il ne paie jamais : les décaissements sortent de votre
        solde de transfert, globalement. Ce chiffre ne décroît donc jamais. Le
        prendre pour un disponible ferait payer deux fois.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: Vos comptes actifs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Iban' }
        '403':
          description: |
            La clé n'a pas la portée `iban`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
    post:
      tags: [IBAN de collecte]
      operationId: ouvrirIban
      summary: Faire ouvrir un IBAN de collecte
      description: |
        Ouvre un compte auprès de notre banque partenaire, **à votre nom** ou au
        nom d'un de vos clients (`holder`).

        ⚠ **CETTE ROUTE N'ACCEPTE PAS `Idempotency-Key`, ET C'EST VOULU.** Une
        clé d'idempotence promet « rejouer rend la même réponse ». Ici, rejouer
        ne rend rien : la banque n'offre aucun moyen de relire un client par son
        matricule, donc aucun moyen de savoir si une ouverture a abouti. C'est
        pourquoi un `502 iban_emission_incertaine` veut dire **ne rejouez pas** —
        appelez `GET /v1-ibans` plus tard, le compte y sera s'il a été ouvert.

        ⚠ **UN IBAN OUVERT POUR UN TIERS NE LUI DONNE AUCUN DROIT.** L'argent qui
        y arrive crédite **votre** solde ; `holder` sert à savoir de quel client
        il vient, pas à le lui attribuer.
      security: [{ cleSecrete: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                holder:
                  type: object
                  description: |
                    Absent : le compte est à votre nom. Présent : il est ouvert
                    pour ce client.
                  required: [name, reference]
                  properties:
                    name:
                      type: string
                      minLength: 2
                      maxLength: 120
                      description: Le nom du client, tel qu'il doit figurer sur le compte.
                    reference:
                      type: string
                      pattern: '^[A-Za-z0-9._-]{2,40}$'
                      description: |
                        **Votre** identifiant de ce client. C'est lui qui empêche
                        qu'un appel rejoué ouvre un second compte.
                    tax_id:
                      type: [string, 'null']
                      description: Identifiant fiscal, si la banque l'exige.
      responses:
        '201':
          description: Le compte est ouvert.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Iban' }
        '409':
          description: |
            `iban_already_issued` — vous avez déjà un compte pour cette
            référence (ou à votre nom). En ouvrir un second ferait arriver de
            l'argent sur un compte que rien ne rattache.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '422':
          description: '`invalid_holder` — nom ou référence hors format.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '502':
          description: |
            `iban_emission_incertaine` — la banque n'a pas répondu.
            **Ne rejouez pas** : relisez `GET /v1-ibans`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '503':
          description: |
            `iban_service_inactive` ou `merchant_incomplete` — le service
            d'émission n'est pas disponible, ou votre dossier est incomplet.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-ibans/{id}:
    get:
      tags: [IBAN de collecte]
      operationId: lireIban
      summary: Lire un IBAN et les virements reçus
      description: |
        Le compte, et les **cent derniers virements** qui y sont arrivés — la
        réponse à « qui m'a payé, et sur lequel de mes comptes ».
      security: [{ cleSecrete: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Le compte et ses virements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  iban: { $ref: '#/components/schemas/Iban' }
                  received:
                    type: array
                    items: { $ref: '#/components/schemas/VirementRecu' }
        '404':
          description: Aucun IBAN avec cet identifiant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-ibans/{id}/simulate-transfer:
    post:
      tags: [IBAN de collecte]
      operationId: simulerVirement
      summary: Simuler un virement reçu (sandbox seulement)
      description: |
        Fait arriver un virement sur un IBAN **de test**. Il suit exactement le
        chemin d'un virement réel : constaté, rattaché par numéro de compte,
        crédité sur votre solde de transfert, puis annoncé par `topup.succeeded`.

        ⚠ **Clés de test uniquement** : une clé live reçoit `404`. La même
        `reference` rejouée n'est créditée qu'une fois (`duplicate: true`).
      security: [{ cleSecrete: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: integer, minimum: 1, maximum: 100000000, example: 250000 }
                reference:
                  type: string
                  description: La référence du virement, comme la banque l'écrirait. Générée si absente.
                  example: VIR-2026-0042
                label: { type: string, description: Le libellé du relevé. }
      responses:
        '201':
          description: Virement constaté.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [bank_transfer] }
                  sandbox: { type: boolean }
                  iban_id: { type: string }
                  reference: { type: string }
                  amount: { type: integer }
                  credited: { type: boolean }
                  duplicate: { type: boolean }
                  reason: { type: [string, 'null'] }
        '200':
          description: Cette référence avait déjà été constatée — rien n'est crédité de plus.
        '404':
          description: Clé live, ou IBAN inconnu.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '422':
          description: Montant ou référence invalide.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-settlements:
    post:
      tags: [Règlements]
      operationId: demanderReglement
      summary: Demander à être réglé
      description: |
        **Ce n'est pas un décaissement.** Un décaissement (`/v1-payouts`) paie un
        TIERS depuis votre solde de transfert, que vous avez approvisionné, et
        il est facturé. Un règlement vous rend à VOUS ce que nous vous devons,
        depuis votre disponible, et il est gratuit : récupérer son propre argent
        ne se facture pas.

        **Elle rend `202`, jamais `201`.** Une demande est ACCEPTÉE, pas
        exécutée : elle attend une validation de nos équipes. Un intégrateur qui
        traiterait un `2xx` comme « l'argent est parti » se tromperait ; le code
        HTTP le dit autant que le champ `status`.

        **Exige la portée `payout`.** Cette route fait sortir de l'argent : une
        clé de site marchand compromise ne doit pas pouvoir vider le compte.

        ### La destination ne se choisit pas par API *(depuis le 2026-09-05)*

        `beneficiary` doit être **la destination de règlement désignée** par le
        propriétaire du compte, depuis la console, dans l'écran « Règlements ».
        Il n'y en a qu'une à la fois.

        Ce n'est pas une formalité : `POST /v1-payouts` enregistre une
        destination à la volée, et `POST /v1-beneficiaries` en crée une
        explicitement — toutes deux sous la portée `payout`. Sans cette règle,
        une clé compromise n'aurait eu qu'à s'ajouter sa propre destination puis
        à appeler cette route : **la portée `payout` aurait servi à contourner
        la portée `payout`**. Un décaissement paie un tiers ; décider où le
        marchand se fait payer n'appartient pas à une clé.

        Une destination du carnet de décaissement est donc refusée ici
        (`destination_non_designee`), et une destination fraîchement désignée
        attend un délai avant de servir (`destination_trop_recente`) — le
        marchand et son équipe en sont avertis par courriel, pour qu'un
        changement non voulu laisse le temps de réagir.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, beneficiary]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: |
                    Unité mineure, **entier JSON strict**. `"50000"` est refusé :
                    sur un montant qui sort, la conversion implicite d'une
                    chaîne est une faute, pas une commodité.
                  example: 50000
                beneficiary:
                  type: string
                  format: uuid
                  description: Identifiant d'une destination de votre carnet.
      responses:
        '202':
          description: |
            Demande **acceptée**, en attente de validation. Rien n'est encore
            versé. Suivez `status` — ou l'événement `settlement.paid`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reglement' }
        '400':
          description: '`invalid_body` — le corps n''est pas du JSON valide.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: |
            `destination_introuvable` — cette destination n'est pas dans votre
            carnet. Enregistrez-la d'abord depuis votre console.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `idempotency_key_reused` (même clé, corps différent) ou
            `idempotency_in_progress` (une requête portant cette clé est en cours).
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `idempotency_key_required`, `invalid_amount`, `invalid_beneficiary`,
            `sous_le_plancher` (montant sous le minimum de versement) ou
            `montant_non_eligible` (vous ne disposez pas de cette somme).
            `destination_non_designee` — cette destination sert aux
            décaissements ; celle des règlements se désigne en console.
            `destination_trop_recente` — désignée il y a moins de N heures ; le
            message dit à partir de quand elle servira.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `settlement_failed` — une panne de notre côté, jamais une faute de
            votre requête. **Réessayez avec la MÊME clé d'idempotence** : c'est
            ce qui garantit qu'une demande ne sera pas déposée deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
    get:
      tags: [Règlements]
      operationId: listerReglements
      summary: Lister vos demandes, ou en lire une
      description: |
        Sans rien, rend vos demandes les plus récentes.

        Pour en lire une seule, l'identifiant se place **en fin de chemin** :
        `…/v1-settlements/stl_XXXXXXXX` — comme pour les paiements. Un
        règlement appartenant à un autre marchand rend **404**, jamais 403 :
        répondre 403 confirmerait son existence.
      parameters:
        - name: id
          in: query
          required: false
          schema: { type: string }
          description: Fourni en fin de CHEMIN, pas en paramètre. Voir la description.
      responses:
        '200':
          description: La liste, ou la demande demandée.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reglement' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '404':
          description: '`resource_not_found` — cette demande n''existe pas, ou n''est pas la vôtre.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

# ═══ WEBHOOKS ═══
#
# Documentés ici parce qu'ils ne l'étaient nulle part : la spec ne contenait
# qu'une seule occurrence du mot « webhook » et aucune du mot « signature ».
# Un intégrateur pouvait donc configurer un endpoint depuis sa console et
# n'avoir aucun moyen de vérifier ce qu'il recevait. Relevé le 2026-09-02.
#
# Les valeurs ci-dessous sont celles du code déployé (`_shared/webhook.ts`,
# `worker-webhooks`) — pas une intention.
  /v1-webhook-endpoints:
    get:
      tags: [Webhooks]
      operationId: listerEndpointsWebhook
      summary: Lister vos endpoints de webhook
      description: |
        Les endpoints **du mode de votre clé** : une clé `vp_sk_test_` voit vos
        endpoints de test, une clé `vp_sk_live_` ceux de production.

        ⚠ **UNE CLÉ NE VOIT QUE LES ENDPOINTS DE SA PORTÉE** — ceux dont tous
        les événements relèvent de son service (`collect` → `payment.*` ;
        `payout` → `payout.*`, `payout_request.*` ; `iban` → `iban.*`,
        `topup.*`). Un endpoint abonné à « tous les événements » se gère depuis
        votre console. Voir `POST /v1-webhook-endpoints` pour la raison.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: Vos endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/EndpointWebhook' }
    post:
      tags: [Webhooks]
      operationId: creerEndpointWebhook
      summary: Déclarer un endpoint de webhook
      description: |
        Déclare une URL qui recevra les événements choisis, **dans le mode de
        votre clé**. La réponse porte le **secret de signature**, affiché
        **une seule fois** : conservez-le, il sert à vérifier l'en-tête
        `LibrePay-Signature` de chaque envoi.

        ⚠ **UNE CLÉ N'ABONNE QU'AUX ÉVÉNEMENTS DE SA PORTÉE.** Une clé de site
        marchand (`collect`) est la plus exposée : si elle pouvait abonner une
        URL de son choix à vos décaissements, sa fuite ferait sortir le détail
        de chaque paiement fournisseur. `["*"]` (tous les événements) se règle
        depuis la console.

        ⚠ **L'URL EST CONTRÔLÉE** : HTTPS, sans identifiants, et jamais vers une
        adresse privée ou réservée (`10.x`, `192.168.x`, `localhost`…).

        ⚠ **PAS D'`Idempotency-Key` ICI** : la réponse porte un secret qu'on ne
        conserve pas. En cas de doute sur une création, listez vos endpoints.
      security: [{ cleSecrete: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, enabled_events]
              properties:
                url: { type: string, format: uri, maxLength: 2048, example: 'https://votre-serveur.ci/webhooks/librepay' }
                enabled_events:
                  type: array
                  minItems: 1
                  items: { type: string }
                  description: 'Types à recevoir. Liste : `GET /v1-webhook-endpoints/event-types`.'
                  example: [payment.succeeded, payment.failed]
                description: { type: string, maxLength: 200 }
      responses:
        '201':
          description: L'endpoint, **et son secret** (une seule fois).
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/EndpointWebhook' }
                  - type: object
                    properties:
                      secret: { type: string, example: 'whsec_4f1c…' }
        '403':
          description: '`insufficient_scope` — un événement demandé n''est pas de la portée de cette clé.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '422':
          description: |
            `invalid_url`, `unknown_event_type` (le type est nommé dans le
            message), `invalid_enabled_events` (`["*"]` ou liste vide),
            `invalid_description`, `too_many_endpoints` (16 actifs par mode).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-webhook-endpoints/event-types:
    get:
      tags: [Webhooks]
      operationId: typesEvenementsWebhook
      summary: Le catalogue des événements
      description: |
        Les types auxquels un endpoint peut s'abonner, la portée de clé qui y
        donne droit, et s'ils sont accessibles à **la clé qui appelle**.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: Le catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string, example: payment.succeeded }
                        description: { type: string }
                        scope: { type: string, enum: [collect, payout, iban] }
                        available_to_this_key: { type: boolean }

  /v1-webhook-endpoints/{id}:
    get:
      tags: [Webhooks]
      operationId: lireEndpointWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Lire un endpoint
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: L'endpoint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EndpointWebhook' }
        '404':
          description: Introuvable — ou hors de la portée ou du mode de cette clé.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
    post:
      tags: [Webhooks]
      operationId: modifierEndpointWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Modifier un endpoint
      description: |
        Chaque champ fourni remplace l'ancien ; un champ absent est inchangé.
        `description: ""` l'efface. **Le mode ne se modifie pas** : pour
        passer en production, déclarez un endpoint avec une clé `vp_sk_live_`.
      security: [{ cleSecrete: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                enabled_events: { type: array, items: { type: string } }
                description: { type: string, maxLength: 200 }
      responses:
        '200':
          description: L'endpoint modifié.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EndpointWebhook' }
        '403':
          description: '`insufficient_scope`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '422':
          description: Mêmes refus qu'à la création.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-webhook-endpoints/{id}/disable:
    post:
      tags: [Webhooks]
      operationId: desactiverEndpointWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Désactiver un endpoint
      description: |
        Il ne reçoit plus rien. Un endpoint ne se supprime pas : son historique
        de livraisons reste consultable.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: L'endpoint, `status = disabled`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EndpointWebhook' }

  /v1-webhook-endpoints/{id}/enable:
    post:
      tags: [Webhooks]
      operationId: activerEndpointWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Réactiver un endpoint
      description: |
        Y compris un endpoint que nous avons coupé après des échecs répétés :
        le compteur d'échecs repart de zéro. Les événements manqués pendant la
        coupure ne sont pas renvoyés d'office — rejouez-les depuis la console.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: L'endpoint, `status = enabled`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EndpointWebhook' }
        '422':
          description: '`too_many_endpoints`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

  /v1-webhook-endpoints/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: renouvelerSecretWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Renouveler le secret de signature
      description: |
        ⚠ **L'ANCIEN SECRET CESSE DE SIGNER IMMÉDIATEMENT** — pas de période de
        chevauchement : un secret qu'on renouvelle est en général un secret
        qu'on croit compromis. Déployez le nouveau chez vous aussitôt ; les
        livraisons qui échoueront entre-temps se rejouent.
      security: [{ cleSecrete: [] }]
      responses:
        '200':
          description: L'endpoint, **et le nouveau secret** (une seule fois).
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/EndpointWebhook' }
                  - type: object
                    properties:
                      secret: { type: string }

  /v1-webhook-endpoints/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testerEndpointWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      summary: Envoyer un événement de test
      description: |
        Envoie `webhook.test` à **cet endpoint seul**, par le vrai chemin :
        même file, même signature, mêmes réessais. Aucun argent ne bouge. C'est
        ce qui prouve que votre serveur reçoit **et vérifie la signature** sans
        attendre un vrai paiement. Un test toutes les dix secondes au plus.
      security: [{ cleSecrete: [] }]
      responses:
        '202':
          description: Livraison programmée.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [webhook_test] }
                  event_id: { type: string, example: evt_… }
                  type: { type: string, enum: [webhook.test] }
                  status: { type: string, enum: [pending] }
        '409':
          description: '`endpoint_disabled` — réactivez-le d''abord.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }
        '429':
          description: '`test_rate_limited` — un test est parti il y a moins de dix secondes.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Erreur' }

webhooks:
  evenement:
    post:
      tags: [Webhooks]
      operationId: recevoirEvenement
      summary: Ce que nous envoyons à votre endpoint
      description: |
        Nous appelons l'URL que vous avez enregistrée en `POST`, avec le corps
        JSON ci-dessous et trois en-têtes :

        | En-tête | Contenu |
        |---|---|
        | `LibrePay-Signature` | `t=<horodatage unix>,v1=<HMAC-SHA256 hexadécimal>` |
        | `LibrePay-Event-Id` | `evt_…` — l'identifiant de l'événement |
        | `User-Agent` | `LibrePay-Webhooks/1.0` |

        ### Vérifier la signature

        Le HMAC-SHA256 porte sur **`t + "." + corps brut`**, avec votre secret
        d'endpoint comme clé — **pas sur le corps seul**. Sans l'horodatage dans
        le message signé, on pourrait rejouer un ancien appel authentique en
        changeant simplement le `t` affiché.

        ```
        message = "1755770000." + corps_brut_exact
        attendu = hex(hmac_sha256(secret_endpoint, message))
        valide  = attendu == v1  et  |maintenant - t| <= 300 secondes
        ```

        Signez sur le corps **tel qu'il est arrivé**, avant tout parsing : le
        moindre reformatage JSON change l'empreinte.

        La tolérance est de **300 secondes**. Une signature authentique mais
        plus ancienne est refusée — c'est ce qui borne le rejeu.

        ### Répondre

        Répondez **2xx** dès que vous avez enregistré l'événement, sans attendre
        votre propre traitement. Toute autre réponse, ou un dépassement de
        délai, déclenche une nouvelle tentative avec un intervalle croissant.
        Les livraisons échouées sont visibles dans votre console, et rejouables.

        ### Traitez-les comme idempotents

        Un même `LibrePay-Event-Id` peut vous parvenir plusieurs fois — un rejeu,
        une réponse perdue en chemin. Déduisez-en votre état à partir de
        `data.status`, et ignorez un identifiant déjà vu.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Evenement' }
      responses:
        '200':
          description: |
            Reçu. **Toute réponse hors 2xx entraîne une nouvelle tentative** —
            y compris un 3xx : une redirection n'est pas un accusé de réception.

components:
  securitySchemes:
    cleSecrete:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer vp_sk_test_…` en sandbox,
        `vp_sk_live_…` en production. **Serveur à serveur uniquement** : une clé
        secrète dans une application mobile ou une page web est une clé publiée.

        ### Restreignez votre clé à vos adresses

        Depuis votre console, onglet « Clés et intégration », vous pouvez
        n'autoriser une clé que depuis certaines adresses — une adresse
        (`41.202.0.7`) ou un bloc (`41.202.0.0/24`). Une clé qui fuite devient
        alors inutilisable ailleurs. C'est la protection la plus efficace du
        lot, et elle ne coûte rien à poser.

        **Un appel refusé par la restriction rend le même `401 invalid_api_key`
        qu'une clé inconnue** — nous ne dirons jamais à un appelant qu'il tient
        une clé valable mais appelle du mauvais endroit. Vous, en revanche, le
        voyez : votre console affiche la dernière adresse refusée et l'instant.
        C'est là qu'il faut regarder si une intégration cesse soudain de passer.

        Vider la liste retire la restriction. Se tromper d'adresse ne vous
        enferme donc jamais dehors : la console s'authentifie par session, pas
        par clé.

  parameters:
    CleIdempotence:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 8 }
      description: |
        Obligatoire. Un identifiant que VOUS choisissez et conservez : c'est lui
        qui garantit qu'un appel rejoué — après un délai réseau, par exemple — ne
        déplace pas l'argent deux fois.

  headers:
    RequestId:
      description: |
        Identifiant de la requête, à citer au support.

        **Un seul nom depuis le 2026-09-18.** Les anciens en-têtes
        `Valsoria-*`, envoyés en double depuis le 2026-09-07, ont été retirés —
        annonce au changelog. Lisez `LibrePay-Request-Id`, et
        `LibrePay-Signature` / `LibrePay-Event-Id` sur les webhooks.
      schema: { type: string, format: uuid }
    RetryAfter:
      description: |
        Secondes à attendre avant de réessayer. **Respectez-le** : réessayer
        avant produit exactement le martèlement que la limite refuse.
      schema: { type: integer, example: 42 }
    RateLimitLimit:
      description: Le plafond en vigueur, en appels par minute.
      schema: { type: integer, example: 300 }
    RateLimitRemaining:
      description: Ce qu'il reste dans la fenêtre courante.
      schema: { type: integer, example: 0 }
    RateLimitReset:
      description: Secondes avant que la fenêtre reparte à zéro.
      schema: { type: integer, example: 42 }

  responses:
    TropDAppels:
      description: |
        **`rate_limit_exceeded`** — vous avez dépassé le nombre d'appels permis
        dans la minute.

        ### Deux limites, et elles ne comptent pas la même chose

        - **par clé API**, 300 appels/minute par défaut. C'est la limite qui vous
          concerne. Elle porte sur l'identifiant de votre clé : personne d'autre
          ne peut consommer votre quota, même en connaissant son préfixe.
        - **par adresse IP**, 600 appels/minute par défaut. Elle s'applique avant
          l'authentification et vise un martèlement sans clé valable. Vous ne la
          rencontrerez que si plusieurs de vos serveurs sortent par la même
          adresse en appelant très fort.

        Les deux plafonds se règlent de notre côté sans mise en ligne : si votre
        volume légitime les dépasse, **écrivez-nous** plutôt que de contourner.

        ### Ce que la réponse vous donne pour réagir

        `Retry-After` dit en combien de secondes réessayer, et `RateLimit-Limit`
        rappelle le plafond appliqué. Un client sérieux les lit et ralentit ; un
        client qui reboucle aggrave son cas.

        ### La fenêtre est FIXE, et on le dit

        Le compteur repart à zéro à chaque minute pleine, il ne glisse pas. À
        cheval sur deux fenêtres, vous pouvez donc envoyer jusqu'à deux fois le
        plafond en un instant sans être refusé. Ne construisez pas votre cadence
        là-dessus : c'est une propriété de l'implémentation, pas une promesse.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erreur' }

  schemas:
    Paiement:
      type: object
      properties:
        id: { type: string, example: txn_6INMt2lIL1Pv_y6RgqZvag }
        object: { type: string, enum: [payment] }
        amount:
          type: integer
          example: 10000
          description: Ce que le marchand a demandé, en unité mineure.
        amount_collected:
          type: integer
          example: 10000
          description: |
            Ce que le PAYEUR débourse. Égal à `amount` quand le marchand
            supporte les frais ; `amount + fee_amount` quand c'est le payeur —
            le barème le décide, par marchand et par service.
        fee_amount:
          type: integer
          example: 150
          description: |
            Commission LibrePay, **figée à la création** : un changement de
            barème ne modifie jamais une transaction passée. Rendue dès la
            réponse de création, avant que l'argent ne bouge.
        net_amount:
          type: integer
          example: 9850
          description: |
            Ce que le marchand touche réellement, crédité à la confirmation :
            le montant encaissé moins la commission figée à la création.
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [created, pending, succeeded, failed, expired, refunded, partially_refunded]
          description: |
            `created` : enregistré. `pending` : le connecteur travaille.
            `succeeded` : la preuve est arrivée. `failed` : refus établi.

            **Il n'existe pas d'état « probablement réussi ».** Tant qu'une issue
            est incertaine, le paiement reste `pending` — jamais `failed`, ce qui
            vous ferait conclure à tort qu'aucun argent n'est parti.
        operator: { type: [string, 'null'] }
        merchant_reference: { type: [string, 'null'] }
        failure_code:
          type: [string, 'null']
          example: amount_too_small
          description: |
            **Pourquoi le paiement a échoué**, en familles stables d'un
            opérateur à l'autre. Renseigné seulement sur `failed`.

            | Code | Ce qu'il dit |
            |---|---|
            | `insufficient_funds` | Le payeur n'a pas la somme. |
            | `declined_by_payer` | Il a refusé, ou n'a pas saisi son code. |
            | `invalid_recipient` | Le numéro n'est pas servi par cet opérateur. |
            | `limit_exceeded` | Un **plafond** de l'opérateur est dépassé. |
            | `amount_too_small` | Le montant est sous le **minimum** de l'opérateur. C'est l'inverse du précédent : proposez PLUS, pas moins. |
            | `operator_unavailable` | L'opérateur n'a pas répondu, ou son service est coupé. |
            | `timeout` | Nous n'avons pas su conclure dans le délai. |
            | `expired` | Accepté par l'opérateur, jamais confirmé par le payeur. |
            | `unreadable_response` | L'opérateur a répondu quelque chose que nous ne savons pas lire. |
            | `unknown` | Refus sans motif exploitable. |

            **Cette liste s'allongera** — un opérateur nomme un jour une cause
            qu'aucun autre ne nommait. Prévoyez une branche par défaut plutôt
            que d'échouer sur un code inconnu.
        failure_message: { type: [string, 'null'] }
        redirect_url:
          type: [string, 'null']
          example: https://pay.wave.com/c/cos-xxxx
          description: |
            **Où envoyer votre client pour qu'il paie**, lorsque l'opérateur
            héberge sa propre page.

            Sur Orange Money, MTN MoMo et Moov Money, l'opérateur pousse un
            dialogue sur le téléphone de votre client : il n'a nulle part où
            aller, et ce champ vaut `null`.

            **Sur Wave, c'est le seul chemin vers l'argent.** Tant que votre
            client n'a pas ouvert cette page, rien ne se passe : pas de
            dialogue, pas de SMS, aucun événement — et le paiement expire.

            ⚠ **Il est `null` dans la réponse de création**, et ce n'est pas un
            oubli : à cet instant, l'opérateur n'a pas encore été appelé.
            Récupérez-le par `GET /v1/payments/{id}` (il apparaît en une
            seconde ou deux), ou abonnez-vous à l'événement
            `payment.action_required`, qui le porte et n'est émis que lorsqu'il
            y a réellement quelque chose à faire.

            Il n'est rendu que tant que l'issue est ouverte (`created`,
            `pending`). Après coup, la page de l'opérateur proposerait à votre
            client de payer une seconde fois.
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        succeeded_at: { type: [string, 'null'], format: date-time }
        failed_at: { type: [string, 'null'], format: date-time }

    Iban:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [iban] }
        rib:
          type: string
          description: Avec ses espaces, tel que la banque l'écrit — c'est ce qu'on communique.
          example: CI93 CI201 01001 127586883703 85
        iban:
          type: string
          description: Le même, compacté — celui qu'on colle dans un virement.
          example: CI93CI20101001127586883703850
        account_number:
          type: string
          description: Le numéro de compte. C'est lui que la banque nomme dans son relevé.
        holder: { type: string }
        reference: { type: string, description: Le matricule attribué par la banque. }
        for_third_party:
          type: [object, 'null']
          description: |
            `null` = le compte est à votre nom. ⚠ Cela ne dit **pas** à qui
            l'argent revient : un virement reçu crédite toujours le marchand.
          properties:
            name: { type: string }
            reference: { type: string }
            tax_id: { type: [string, 'null'] }
        branch: { type: [string, 'null'] }
        bank_status: { type: [string, 'null'] }
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        received_total:
          type: integer
          description: |
            Le **cumul** de ce qui est arrivé. ⚠ Ce n'est pas un solde : il ne
            décroît jamais.
        received_count: { type: integer }
        last_received_on: { type: [string, 'null'], format: date }

    VirementRecu:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [bank_transfer] }
        reference: { type: string, description: La référence bancaire du virement. }
        amount: { type: integer }
        currency: { type: string, example: XOF }
        received_on:
          type: string
          format: date
          description: |
            La date d'**opération** à la banque — pas l'heure où nous l'avons
            constatée. Nous relisons son relevé toutes les cinq minutes.
        label: { type: [string, 'null'], description: Le libellé tel que la banque l'écrit. }
        from_bank: { type: [string, 'null'] }
        credited_at:
          type: [string, 'null']
          format: date-time
          description: Quand votre solde a été crédité. `null` = pas encore rapproché.

    Decaissement:
      type: object
      properties:
        id: { type: string, example: txn_6INMt2lIL1Pv_y6RgqZvag }
        object: { type: string, enum: [payout] }
        amount:
          type: integer
          description: Ce que reçoit le bénéficiaire.
          example: 50000
        fee_amount:
          type: integer
          description: Commission, **figée à la création**.
          example: 250
        total_debited:
          type: integer
          description: Ce qui sort de votre solde de transfert — `amount + fee_amount`.
          example: 50250
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [created, processing, succeeded, failed, indeterminate]
          description: |
            `indeterminate` : l'opérateur n'a pas confirmé. **Nous ne rejouons
            jamais un envoi incertain** — ne le rejouez pas non plus.
        beneficiary:
          type: object
          properties:
            name: { type: [string, 'null'] }
            label: { type: [string, 'null'] }
            msisdn: { type: [string, 'null'] }
        reference: { type: [string, 'null'] }
        failure_code: { type: [string, 'null'] }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }

    DemandeDecaissement:
      type: object
      description: |
        Un décaissement **retenu** par un plafond. Rien n'est parti ; un humain
        doit trancher depuis la console.
      properties:
        id: { type: string, example: por_3f2c1a90-1d4e-4e2b-9c11-0a7d5e2b4f88 }
        object: { type: string, enum: [payout_request] }
        status:
          type: string
          enum: [pending_approval, approuve, refuse, expire]
        amount: { type: integer, example: 150000 }
        currency: { type: string, example: XOF }
        reason:
          type: string
          enum: [plafond_operation, plafond_24h]
          description: Quel plafond est en cause — pour savoir lequel relever.
        limit:
          type: integer
          description: La valeur du plafond atteint.
          example: 100000
        reference: { type: [string, 'null'] }
        payout_id:
          type: [string, 'null']
          description: |
            Le décaissement créé quand la demande est **approuvée** (`txn_…`) —
            c'est lui que portent ensuite les webhooks `payout.*`. `null` tant
            qu'elle n'est pas approuvée.
        refusal_reason: { type: [string, 'null'], description: 'Le motif, si elle a été refusée en console.' }
        decided_at: { type: [string, 'null'], format: date-time }
        expires_at:
          type: string
          format: date-time
          description: |
            Passé ce délai, la demande est `expire` (et un webhook
            `payout_request.expired` part) — rien n'est parti ; refaites l'appel.
        created_at: { type: string, format: date-time }
        message: { type: string }

    LotDecaissement:
      type: object
      properties:
        id: { type: string, example: pob_3f2c1a90-1d4e-4e2b-9c11-0a7d5e2b4f88 }
        object: { type: string, enum: [payout_batch] }
        status:
          type: string
          enum: [brouillon, en_attente_validation, en_cours, termine,
                 termine_avec_erreurs, annule, refuse]
          description: |
            `brouillon` : créé, **rien n'est parti**. `en_attente_validation` :
            le total dépasse le plafond, une validation humaine est requise en
            console. `termine_avec_erreurs` : certaines lignes ne sont pas
            parties — **ce n'est pas `termine`**, et le confondre ferait passer
            des lignes impayées pour une paie faite.
        reference: { type: [string, 'null'] }
        total: { type: integer, example: 120000 }
        line_count: { type: integer, example: 3 }
        currency: { type: string, example: XOF }
        reason:
          type: string
          enum: [plafond_operation, plafond_24h]
          description: Présent seulement si le lot attend une validation.
        limit: { type: integer }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        lines:
          type: array
          description: Présent sur `GET` et après exécution.
          items:
            type: object
            properties:
              rank:
                type: integer
                description: L'ordre du fichier fourni — c'est par lui qu'on retrouve une ligne fautive.
              amount: { type: integer }
              reference: { type: [string, 'null'] }
              status: { type: string, enum: [a_envoyer, envoye, echoue, ignore] }
              payout_id: { type: [string, 'null'], example: txn_… }
              failure_code: { type: [string, 'null'] }
              failure_message: { type: [string, 'null'] }

    SessionCheckout:
      type: object
      properties:
        id: { type: string, example: cs_JBpm7AnJZvBBRHcloKKIfg }
        object: { type: string, enum: [checkout_session] }
        amount: { type: integer, example: 10000 }
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [open, processing, succeeded, failed, expired, cancelled]
        expires_at: { type: string, format: date-time }
        url:
          type: string
          format: uri
          description: |
            Le lien à transmettre au payeur. Le jeton qu'il porte n'est pas
            devinable — c'est ce qui protège la session.
          example: https://pay.valsoriapay.avasoftware.net/c/tok_…

    Tarification:
      type: object
      properties:
        object: { type: string, enum: [pricing] }
        livemode: { type: boolean, description: Le mode de la clé qui a lu ce tarif. }
        currency: { type: string, example: XOF }
        resolved_at: { type: string, format: date-time, description: Instant de la résolution. }
        services:
          type: object
          properties:
            collect: { $ref: '#/components/schemas/TarifService' }
            payout: { $ref: '#/components/schemas/TarifService' }
    TarifService:
      type: object
      properties:
        standard:
          description: La règle générale du service, ou `null` si aucune ne s'applique.
          oneOf:
            - { $ref: '#/components/schemas/RegleTarif' }
            - { type: 'null' }
        operators:
          type: array
          description: Les règles qui nomment un opérateur.
          items: { $ref: '#/components/schemas/RegleTarif' }
    RegleTarif:
      type: object
      properties:
        operator: { type: [string, 'null'], example: orange_ci, description: '`null` pour la règle générale.' }
        percent_bp: { type: integer, description: 'Part proportionnelle, en points de base (100 = 1 %).', example: 150 }
        fixed: { type: integer, description: Part fixe, en unité mineure. }
        min_fee: { type: [integer, 'null'], description: Commission minimale. }
        max_fee: { type: [integer, 'null'], description: Commission maximale. }
        paid_by: { type: string, enum: [merchant, payer], description: 'Qui supporte la commission, quand la règle le précise.' }
        source: { type: string, enum: [merchant, platform], description: '`merchant` : un barème négocié pour vous.' }
        fallback: { type: boolean, description: "`true` : l'opérateur n'a pas de règle propre, la règle générale s'applique." }
        rule_id: { type: [string, 'null'] }
        effective_since: { type: [string, 'null'], format: date-time }
    ListeOperateurs:
      type: object
      properties:
        object: { type: string, enum: [operator_list] }
        currency: { type: string, example: XOF }
        standard:
          type: object
          description: |
            Le barème **de repli**, par service : celui qui s'applique quand
            aucune règle ne nomme l'opérateur. C'est le chiffre d'en-tête d'une
            page de tarifs.

            ⚠ Ce n'est **ni une moyenne ni un « à partir de »** calculé sur la
            liste ci-dessous : c'est la règle `operator is null`, celle que nous
            appliquons à défaut d'une plus précise. Un opérateur peut donc
            coûter plus cher — ou moins — que ce chiffre.

            Les bornes de montant qu'il porte sont celles de la règle seule :
            aucun opérateur n'étant nommé, il n'y a pas d'intersection à faire
            avec ce que son menu accepte.

            **Absent** si aucune règle de repli n'existe.
          properties:
            collect: { $ref: '#/components/schemas/OffreService' }
            payout: { $ref: '#/components/schemas/OffreService' }
        services:
          type: object
          description: |
            Une entrée par service. Un service ABSENT n'est servi par aucun
            opérateur aujourd'hui — ce n'est pas la même chose qu'une liste
            vide, et aucun des deux ne se produit sur incident (voir le 503).
          properties:
            collect:
              type: array
              items: { $ref: '#/components/schemas/OffreOperateur' }
            payout:
              type: array
              items: { $ref: '#/components/schemas/OffreOperateur' }

    OffreService:
      type: object
      description: Un tarif et des bornes, sans opérateur attaché.
      properties:
        min_amount: { type: integer, nullable: true, example: 100 }
        max_amount: { type: integer, nullable: true, example: 2000000 }
        pricing: { $ref: '#/components/schemas/Tarif' }

    Tarif:
      type: object
      properties:
        percent_bp:
          type: integer
          description: Commission en points de base. `140` = 1,40 %.
          example: 140
        fixed:
          type: integer
          description: Part fixe ajoutée au pourcentage, unité mineure.
          example: 0
        min_fee:
          type: integer
          nullable: true
          description: |
            Plancher sur la COMMISSION, pas sur la transaction. Sur un petit
            montant, c'est lui qui s'applique plutôt que le pourcentage.
          example: 25
        max_fee:
          type: integer
          nullable: true
          description: Plafond sur la commission. `null` = aucun.
        paid_by:
          type: string
          enum: [merchant, payer]
          description: |
            Qui supporte la commission. `payer` : elle s'AJOUTE au montant, le
            payeur verse davantage. `merchant` : elle est prélevée, le marchand
            reçoit le montant moins la commission.

    OffreOperateur:
      type: object
      properties:
        operator: { type: string, example: orange_ci }
        name:
          type: string
          description: |
            Le libellé tel que le canal le connaît. **Absent** quand il ne le
            porte pas : affichez alors le code, visible donc corrigible, plutôt
            que rien.
          example: Orange Money Côte d'Ivoire
        min_amount:
          type: integer
          nullable: true
          description: |
            Montant minimum accepté, unité mineure. `null` = borne inconnue —
            **jamais zéro**, qui se lirait « aucun minimum ».
          example: 100
        max_amount:
          type: integer
          nullable: true
          description: Montant maximum accepté, unité mineure. `null` = borne inconnue.
          example: 2000000
        pricing:
          allOf:
            - $ref: '#/components/schemas/Tarif'
          description: |
            Le barème PUBLIC de la plateforme pour ce couple service/opérateur.
            **Absent** quand aucune règle ne s'applique : ne le lisez jamais
            comme « gratuit ». Un barème négocié n'apparaît pas ici — cette
            route ne sait pas qui l'interroge.

    Beneficiaire:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [beneficiary] }
        type: { type: string, enum: [mobile_money, bancaire] }
        label: { type: string, example: Mon compte Orange }
        holder: { type: string, example: Awa Koné }
        operator:
          type: string
          nullable: true
          description: |
            Pour une destination mobile money. Le préfixe du numéro doit
            correspondre au réseau : **07** Orange, **05** MTN, **01** Moov.
            Wave accepte les trois — un compte Wave est adossé à l'une de ces
            lignes.
          example: orange_ci
        bank: { type: string, nullable: true }
        last4:
          type: string
          nullable: true
          description: |
            Les quatre derniers chiffres du numéro ou de l'IBAN. **Jamais la
            valeur complète** : une réponse d'API vit plus longtemps qu'on ne le
            croit.
          example: '7777'
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    Releve:
      type: object
      properties:
        object: { type: string, enum: [statement] }
        from: { type: string, format: date, example: '2026-09-01' }
        to: { type: string, format: date, example: '2026-09-30' }
        currency: { type: string, example: XOF }
        opening_balance:
          type: integer
          description: Votre solde disponible la veille du premier jour, en unité mineure.
          example: 100000
        closing_balance:
          type: integer
          description: |
            Le solde au soir du dernier jour. Sur un relevé arrêté aujourd'hui,
            **il vaut votre disponible** — c'est ce qui permet de rapprocher.
          example: 138000
        total_in: { type: integer, example: 50000 }
        total_out: { type: integer, example: 12000 }
        transfer_balance:
          type: integer
          description: |
            Votre solde de **transfert** — celui qui finance vos décaissements —
            à l'instant de la requête. Le balayage automatique y verse votre
            disponible : sans lui, `closing_balance` seul ne dit pas ce que
            nous détenons pour vous. ⚠ C'est un **instantané**, pas une borne de
            la période : ne l'additionnez pas à la clôture d'un mois passé.
          example: 275060
        transfer_balance_at:
          type: string
          format: date-time
          description: L'instant auquel `transfer_balance` a été lu.
        data:
          type: array
          items:
            type: object
            properties:
              object: { type: string, enum: [statement_line] }
              date: { type: string, format: date-time }
              description:
                type: string
                description: Ce qui s'est passé, en clair — « Encaissement confirmé », « Règlement ».
                example: Encaissement confirmé
              reference:
                type: [string, 'null']
                description: L'identifiant de la transaction, quand la ligne en a une.
                example: txn_6INMt2lIL1Pv_y6RgqZvag
              in:
                type: integer
                description: |
                  Ce qui est entré. **Deux colonnes plutôt qu'un montant signé** :
                  un signe se perd à la relecture, deux colonnes ne se confondent pas.
              out: { type: integer }
              balance:
                type: integer
                description: Le solde APRÈS cette ligne.
        total_count:
          type: integer
          description: Le nombre de mouvements de la PÉRIODE, pas de la page.
        limit: { type: integer }
        offset: { type: integer }
        has_more: { type: boolean }

    Reglement:
      type: object
      properties:
        id: { type: string, example: stl_7bQ2mVx4KpLdR9nZ }
        object: { type: string, enum: [settlement] }
        amount: { type: integer, example: 50000 }
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [demande, validee, payee, refusee, annulee, echouee]
          description: |
            `demande` à l'acceptation. Elle passe `validee` quand nos équipes
            l'approuvent, puis `payee` quand le versement est exécuté. **Seul
            `payee` signifie que l'argent est parti.**
        destination:
          type: object
          description: |
            La destination, jamais en entier : ni IBAN complet, ni numéro
            complet. Une réponse d'API finit dans les journaux de l'intégrateur.
          properties:
            label: { type: string, nullable: true, example: Mon compte Ecobank }
            type: { type: string, nullable: true, enum: [mobile_money, bancaire] }
            holder: { type: string, nullable: true }
        decline_reason:
          type: string
          nullable: true
          description: Renseigné lorsque `status` vaut `refusee`.
        decided_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }

    Evenement:
      type: object
      properties:
        id: { type: string, example: evt_WcrqCQJZDdp2lZ9bkns7kg }
        object: { type: string, enum: [event] }
        type:
          type: string
          description: |
            Les huit types émis aujourd'hui. Cette liste est celle du code, pas
            une intention — elle s'allongera, alors **ignorez poliment un type
            que vous ne connaissez pas** plutôt que d'échouer dessus.
          enum:
            - payment.action_required
            - payment.succeeded
            - payment.failed
            - payment.refunded
            - payment.partially_refunded
            - payout.pending
            - payout.succeeded
            - payout.failed
        api_version:
          type: string
          example: '2026-08-21'
          description: La version de format de `data`. Elle ne change jamais rétroactivement.
        created_at: { type: string, format: date-time }
        data:
          type: object
          description: |
            L'objet concerné, dans la même forme que celle rendue par l'API —
            un `Paiement` pour un `payment.*`, un `Decaissement` pour un
            `payout.*`.
          examples:
            paiement:
              summary: Un `payment.*`
              value:
                id: txn_-2SyMN03-cG3Ge8T9zDHVw
                object: payment
                status: succeeded
                amount: 10000
                amount_collected: 10000
                fee_amount: 0
                net_amount: 10000
                currency: XOF
                operator: orange_ci
                merchant_reference: null
                created_at: '2026-09-02T17:41:57Z'
            decaissement:
              summary: |
                Un `payout.*`. ⚠ Pas de `net_amount` ni d'`amount_collected` :
                ces champs disent « ce que le marchand reçoit » et n'ont de sens
                qu'à l'encaissement. Ce qui sort du solde est `total_debited`.
              value:
                id: txn_2t_6nNeQVqWj_j3fBYFVSw
                object: payout
                status: succeeded
                amount: 10000
                fee_amount: 140
                total_debited: 10140
                currency: XOF
                beneficiary: { name: Ben 0003, label: Ben 0003, msisdn: '+2250700000003' }
                reference: null
                failure_code: null
                livemode: false
                created_at: '2026-09-24T10:54:38Z'

    EndpointWebhook:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [webhook_endpoint] }
        url: { type: string }
        description: { type: [string, 'null'] }
        livemode: { type: boolean, description: 'Le mode de la clé qui l''a créé — figé.' }
        enabled_events:
          type: array
          items: { type: string }
          description: '`["*"]` = tous les événements, y compris ceux ajoutés plus tard.'
        status: { type: string, enum: [enabled, disabled] }
        secret_prefix: { type: string, description: 'Les premiers caractères du secret, pour le reconnaître sans le révéler.' }
        consecutive_failures: { type: integer }
        disabled_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Erreur:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Code stable, à traiter en machine.
              example: invalid_amount
            message:
              type: string
              description: Phrase destinée à un humain. **Ne pas la comparer** — elle peut changer.
