LibrePay Console →
Documentation

Démarrer

IntroductionDémarrageAuthentification

API

PaiementsPage de paiementDécaissementsIBAN de collecteRelevé de compteWebhooksRéférenceErreurs

Outils

Numéros de testSDK JavaScriptVersions

Décaissements

Envoyer de l'argent à quelqu'un — un fournisseur, un coursier, un affilié — depuis votre solde de transfert.

C'est 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 que l'encaissement n'a pas.

1. Il vous faut une clé de décaissement

Une clé d'encaissement ne décaisse pas. Elle reçoit 403 insufficient_scope, et c'est délibéré : la clé qui vit dans votre site marchand — celle qui a le plus de chances de fuiter — n'a aucun pouvoir d'envoi.

Créez une clé dédiée depuis votre console, onglet Compte, en choisissant le service Décaissement. Gardez-la sur un serveur qui ne sert qu'à ça, et révoquez-la seule si vous la soupçonnez.

2. Les plafonds ne refusent pas, ils font attendre

Deux plafonds, réglables dans votre console (onglet Décaissements) :

PlafondDéfautCe qu'il borne
Par opération100 000 FUn seul envoi
Par 24 heures500 000 FLe total glissant, demandes en attente comprises

Au-delà, l'appel ne renvoie pas d'erreur : il rend 202 et la demande part en validation dans votre console. Refuser casserait une paie légitime le jour où elle dépasse ; laisser passer viderait le compte. Un responsable tranche, avec un code reçu par courriel — le même contrôle que pour un décaissement lancé à la main.

Le plafond sur 24 h compte aussi ce qui attend : sans cela, il suffirait d'ouvrir cent demandes sous le plafond.

Les plafonds bornent la perte, ils ne l'empêchent pas. Ils ne vérifient pas la destination : une clé compromise peut envoyer vers n'importe quel numéro, jusqu'au plafond. Gardez-les au plus près de votre usage réel.

Envoyer

POST /v1-payouts
Authorization: Bearer vp_sk_test_…
Idempotency-Key: SAL-08-2026-017

{
  "amount": 50000,
  "currency": "XOF",
  "beneficiary": { "msisdn": "0700000021", "operator": "orange_ci", "name": "Koffi Y." },
  "reference": "SAL-08-2026-017"
}

La destination fournie à l'appel est enregistrée dans votre carnet : c'est ce qui vous permet de relire ensuite, dans votre console, où votre argent est parti. Vous pouvez aussi viser un bénéficiaire existant avec beneficiary_id.

La réponse dit laquelle des deux choses s'est produite

// 201 — c'est parti
{ "id": "txn_…", "object": "payout", "amount": 50000,
  "fee_amount": 250, "total_debited": 50250, "status": "created" }
// 202 — retenu, rien n'est parti
{ "id": "por_…", "object": "payout_request", "status": "pending_approval",
  "reason": "plafond_operation", "limit": 100000 }

Ne traitez pas tout 2xx comme un succès. Un 202 noté « payé » dans votre système, c'est un salaire que vous croyez avoir versé. Testez object, ou utilisez le SDK :

const r = await valsoria.creerDecaissement({ amount: 50000, beneficiary: {…} });
if (LibrePay.enAttenteDeValidation(r)) {
  // r.reason dit quel plafond, r.limit sa valeur.
}

Suivre

GET /v1-payouts/txn_… pour un décaissement, GET /v1-payouts/por_… pour une demande retenue. Une ressource d'un autre marchand rend 404, jamais 403 : répondre 403 confirmerait son existence.

Une demande retenue se conclut toujours, et vous en êtes prévenu : par un webhook payout_request.approved, .refused ou .expired (48 h sans décision). Approuvée, elle porte payout_id — le txn_… créé, à suivre ensuite comme tout décaissement. Refusée ou expirée, rien n'est parti : rendez l'argent que vous aviez réservé chez vous. Relire la demande donne la même réponse, si un webhook s'est perdu.

Les états : created → processing → succeeded ou failed. Et indeterminate — l'opérateur n'a pas confirmé. Nous ne rejouons jamais un envoi incertain, et vous ne devriez pas non plus : nous vérifions auprès de l'opérateur avant de conclure.

Envoyer en masse

Un lot n'est pas « n appels unitaires ». Trois différences, et chacune vous évite un ennui :

Créer, puis exécuter — en deux gestes

POST /v1-payout-batches
Idempotency-Key: PAIE-2026-08

{ "reference": "PAIE-2026-08", "lines": [
  { "amount": 30000, "beneficiary": { "msisdn": "0700000021", "operator": "orange_ci", "name": "Koffi Y." } },
  { "amount": 40000, "beneficiary_id": "…", "reference": "SAL-002" }
] }

Le lot naît brouillon : rien n'est parti. L'exécution est un appel distinct :

POST /v1-payout-batches/pob_…/execute

C'est délibéré : on relit ce qu'on s'apprête à envoyer avant de l'envoyer, ce qui n'a de sens que si les deux gestes sont séparés. POST …/cancel annule tant que rien n'est parti.

À la création, tout est valide ou rien n'est écrit

Une ligne invalide refuse le lot entier — et le message porte le rang :

{ "error": { "code": "invalid_beneficiary",
             "message": "Ligne 47 : beneficiary.msisdn attendu : 10 chiffres…" } }

Un lot partiellement retenu vous obligerait à comparer votre fichier ligne à ligne à ce qui a été gardé — exactement le travail qu'un lot évite. Au plus 500 lignes par lot.

termine_avec_erreurs n'est pas termine

L'exécution rend 200 dans les deux cas. Vérifiez status. Une ligne qui échoue porte son failure_code et son failure_message ; les autres sont parties.

const lot = await valsoria.executerLot('pob_…');
const aReprendre = LibrePay.lignesEnEchec(lot);  // rangs + motifs

Si le total dépasse votre plafond sur 24 h, le lot naît en_attente_validation et refuse de s'exécuter (409) tant qu'un responsable ne l'a pas validé depuis la console, avec un code reçu par courriel.

Le carnet de bénéficiaires, par API

GET /v1-beneficiaries liste votre carnet, POST /v1-beneficiaries y ajoute une destination. Les deux exigent la portée payout.

La liste est paginée : limit (50 par défaut, 100 au maximum), starting_after, et une réponse qui porte total et has_more — vous n'avez donc pas à compter vous-même pour savoir s'il reste des pages.

GET /v1-beneficiaries?limit=50
→ { "data": [...], "total": 128, "limit": 50, "has_more": true }

La création est idempotente sans clé d'idempotence : le carnet est unique par (marchand, mode, coordonnée). Rejouer le même appel rend la même ligne avec un 200 au lieu d'un 201 — pas un doublon.

Payer sur un compte bancaire

Depuis le 2026-09-22, une destination peut être un IBAN et non un numéro de téléphone. Rien d'autre ne change : même route, même idempotence, mêmes états.

POST /v1-beneficiaries
{ "type": "bancaire", "libelle": "Fournisseur Kouassi",
  "titulaire": "KOUASSI SARL",
  "iban": "CI93 CI201 01001 127586883703 85",
  "banque": "BANQUE D ABIDJAN", "operator": "bda_ci" }

operator désigne le canal, pas la banque. bda_ci est la voie par laquelle l'argent part. La banque destinataire se lit dans l'IBAN — et ce peut être n'importe quel établissement de la zone UEMOA. Il n'existe donc pas un opérateur par banque, et il n'en existera pas : ce serait confondre par où l'argent passe avec où il arrive.

Trois différences à prévoir dans votre code :

Ce qui manque encore

Modifier ou archiver une destination par API. On lit et on ajoute ; on ne change ni ne retire depuis l'API. Un décaissement passé doit rester lisible avec la destination qu'il visait vraiment, et une coordonnée qu'on peut réécrire à distance est exactement ce qu'une clé volée réécrirait. Les deux gestes existent dans votre console.

La destination de vos RÈGLEMENTS ne se crée pas ici — ni par cette route, ni par aucune autre. C'est le compte sur lequel nous vous versons : il se désigne dans votre console, par le propriétaire du compte, avec un code de confirmation et un délai avant première utilisation. Voir l'accueil.

Version d'API 2026-08-25 · changements · spécification OpenAPI