Versions et changements
Comment l'API évolue
Nous ne cassons pas une intégration qui fonctionne. Concrètement :
- Un champ peut apparaître dans une réponse à tout moment. Votre code doit ignorer ce qu'il ne connaît pas — c'est la seule chose que nous vous demandons.
- Une valeur peut s'ajouter à une énumération : un nouveau
status, un nouveaufailure_code, un nouveau type d'événement. Prévoyez une branche par défaut. - Un champ existant ne change jamais de sens ni de type.
- Une route ne disparaît jamais sans annonce préalable.
Tout changement qui romprait ces règles serait servi sous une nouvelle version d'API, et l'ancienne continuerait de répondre.
La version
Elle est datée : 2026-08-25. Vous la retrouvez dans chaque événement de webhook, champ api_version — utile quand un événement dort en file pendant une montée de version.
Journal
2026-09-27
Vos webhooks se paramètrent entièrement, par mode, depuis la console et par l'API. Nouvelle route /v1-webhook-endpoints : déclarer, lire, modifier (adresse, description, événements), désactiver et réactiver, changer le secret, envoyer un événement de test. Le catalogue des événements est servi par GET /v1-webhook-endpoints/event-types. Une clé ne gère que les événements de son service.
Dans la console, un endpoint se crée désormais dans le mode affiché : les endpoints de production se déclarent en mode production. Un endpoint reçoit tous les événements, ou ceux que vous choisissez.
Nouveau type d'événement : webhook.test, émis uniquement quand vous le demandez.
2026-09-26
Le domaine de la page de paiement peut dépendre de votre compte. Pour certains comptes, url (session de checkout) désigne une page servie sur un autre domaine que LibrePay. Transmettez-la telle quelle, sans la filtrer ni la reconstruire ; les liens déjà émis restent valables. Voir le checkout.
Une demande de décaissement retenue se conclut, et le dit. Au-delà d'un plafond, POST /v1/payouts répond 202 avec une demande (por_…). Elle porte désormais payout_id une fois approuvée (le champ disparaissait jusqu'ici), refusal_reason et decided_at ; elle expire d'elle-même après 48 h ; et trois événements la concluent : payout_request.approved, .refused, .expired. Voir les décaissements.
Annuler une session de checkout. POST /v1-checkout/{id}/cancel referme une session encore ouverte : son lien cesse d'être payable avant expires_at. Rejouable, cloisonnée par mode, refusée (409 checkout_not_cancellable) dès qu'un paiement est en route ou conclu. Voir la page de paiement. Ajout au SDK : annulerSessionCheckout(id).
2026-09-24
Le service IBAN existe en sandbox. Une clé de test recevait 503 : aucune banque n'était configurée hors production, et l'on ne pouvait éprouver ni l'ouverture d'un IBAN ni l'arrivée d'un virement avant d'ouvrir de vrais comptes. Une banque simulée ouvre désormais des comptes de test (numéros en 999…, scénarios …0003 incertain et …0008 refusé), et POST /v1-ibans/{id}/simulate-transfer fait arriver un virement par le même chemin qu'un virement réel, jusqu'à topup.succeeded.
Le relevé dit aussi votre solde de transfert. GET /v1/statements rend désormais transfer_balance et transfer_balance_at. Depuis que le balayage automatique verse la collecte vers le transfert toutes les cinq minutes, closing_balance seul retombait près de zéro alors que l'argent était chez nous : un rapprochement comptable concluait à un écart de la totalité des fonds. Champ ajouté, rien ne change pour qui l'ignore.
Les webhooks de décaissement portent enfin de quoi s'en servir. La charge d'un payout.pending, payout.succeeded ou payout.failed contenait { id, amount } et rien d'autre. Elle contient désormais la même chose qu'un payment.* : object, currency, status, operator, fee_amount, net_amount, merchant_reference, created_at.
⚠ data.id CHANGE DE VALEUR, ET C'EST UNE EXCEPTION ASSUMÉE À LA RÈGLE ÉNONCÉE PLUS HAUT. Il portait notre identifiant interne — un UUID que l'API publique ne rend nulle part. POST /v1/payouts répond txn_…, GET /v1/payouts/{id} attend txn_… : l'événement était donc le seul endroit où circulait une valeur inutilisable, et aucun code d'intégration ne pouvait en dépendre sans être déjà cassé. Nous l'alignons sur l'identifiant public plutôt que de laisser vivre une incohérence que nous aurions à expliquer à chaque intégration.
Mesuré avant correction, en sandbox : 1 480 payout.succeeded, pas un seul rapprochable par son destinataire.
⚠ Un fee_amount sur un payout.failed n'est pas une dépense. L'échec rend le montant commission comprise, et la charge porte reverted: true. Si reverted est vrai, l'opération vous a coûté zéro, quel que soit fee_amount.
2026-09-22
Le virement bancaire, par l'opérateur bda_ci. Un bénéficiaire de type bancaire — IBAN et banque — devient une destination de décaissement comme une autre. 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 destinataire. bda_ci est la voie par laquelle l'argent part ; la banque qui reçoit se lit dans l'IBAN, et peut être n'importe quel établissement de la zone UEMOA. Ne cherchez pas un opérateur par banque : il n'y en a pas, et il n'y en aura pas.
⚠ Un virement bancaire ne se rembourse pas. La banque n'expose aucune opération inverse : rendre l'argent est un nouveau virement, à demander comme tel. POST /v1-refunds sur un décaissement bda_ci est refusé.
⚠ Les délais ne sont pas ceux du mobile money. Un virement passe par la compensation interbancaire : comptez jusqu'à 24 h (RTGS) ou 72 h (SICA). La transaction reste pending pendant ce temps — ce n'est pas une anomalie, et la re-vérification est automatique.
failure_code possible en plus : invalid_recipient quand l'IBAN est refusé par la banque réceptrice.
L'IBAN de collecte devient accessible par API. GET /v1-ibans liste vos comptes, POST /v1-ibans en fait ouvrir un. Voir IBAN de collecte.
⚠ Une portée de clé nouvelle : iban, distincte de collect et payout. Faire ouvrir un compte bancaire est irréversible : une clé de site qui encaisse ne doit pas pouvoir multiplier les comptes. La clé se crée depuis votre console, et seulement une fois le service ouvert de notre côté.
⚠ POST /v1-ibans n'accepte pas Idempotency-Key, et c'est délibéré : une clé d'idempotence promet que rejouer rend la même réponse. Si la banque ne répond pas, nous ne pouvons pas lui demander si le compte a été créé — il n'y a donc rien à rejouer. Un 502 iban_emission_incertaine veut dire ne rejouez pas : relisez GET /v1-ibans plus tard.
Nouvel événement : iban.created. Un IBAN vient d'être ouvert à votre nom.
Nouvel événement : topup.succeeded. Un virement bancaire reçu sur votre IBAN de collecte crédite votre solde de transfert, et vous êtes prévenu. C'est le seul événement que vous n'avez pas provoqué : personne ne demande un virement entrant, on le constate.
⚠ Si votre point de terminaison n'a aucun filtre (enabled_events vide), il le reçoit déjà — c'est le comportement « vide = tous ». Prévoyez une branche par défaut, comme pour toute valeur nouvelle.
2026-09-21 (suite)
Nouveau failure_code : amount_too_small. Le montant est sous le minimum de l'opérateur — c'est l'inverse de limit_exceeded. Proposez plus, pas moins.
Il existe parce qu'un refus parfaitement explicite d'Orange — « le montant de la transaction est inférieur au montant minimum défini pour ce service » — repartait jusqu'ici en unknown. Le ranger sous limit_exceeded aurait été pire : ce code annonce un plafond, et vous auriez baissé le montant.
La liste complète des failure_code est désormais documentée dans le schéma Paiement de la spec. Elle s'allongera — prévoyez une branche par défaut.
2026-09-21
Wave est raccordé, et il ne fonctionne pas comme les autres. Orange Money, MTN MoMo et Moov Money poussent un dialogue sur le téléphone du payeur. Wave héberge sa propre page : tant que votre client ne l'a pas ouverte, il ne se passe rien, et le paiement expire.
Nouveau champ redirect_url sur le paiement. C'est l'adresse où envoyer votre client. null sur les opérateurs qui poussent — il n'y a nulle part où aller — et le seul chemin vers l'argent sur Wave.
Il est null dans la réponse de création : à cet instant l'opérateur n'a pas encore été appelé. Récupérez-le par GET /v1/payments/{id}, ou par le nouvel événement ci-dessous.
Nouvel événement payment.action_required. Émis uniquement lorsque votre client doit aller quelque part ; il porte redirect_url et ne conclut rien.
{
"type": "payment.action_required",
"data": {
"id": "txn_6INMt2lIL1Pv_y6RgqZvag",
"amount": 10000,
"operator": "wave_ci",
"redirect_url": "https://pay.wave.com/c/cos-xxxx"
}
}
⚠ Correction : payment.pending n'a jamais existé. Notre page Webhooks le listait ; aucun code ne l'émet, et aucune intégration n'en a jamais reçu. Si vous l'attendiez, vous attendiez pour rien. Nous avons corrigé la page plutôt que d'inventer l'événement.
La page de paiement LibrePay gère la redirection elle-même : si vous l'utilisez, vous n'avez rien à changer.
2026-09-18
Nouvelle route publique : GET /v1-operators. Elle rend les opérateurs réellement servis aujourd'hui, par service, avec les montants acceptés et le tarif public de chacun. Sans clé — c'est l'information que notre page Tarifs affiche déjà à tout visiteur.
{
"object": "operator_list",
"currency": "XOF",
"services": {
"collect": [
{ "operator": "orange_ci", "name": "Orange Money Côte d'Ivoire",
"min_amount": 100, "max_amount": 2000000,
"pricing": { "percent_bp": 140, "fixed": 0, "min_fee": 25,
"max_fee": null, "paid_by": "merchant" } }
]
}
}
Ce qu'elle vous évite. Recopier une liste d'opérateurs dans votre écran de paiement. Une liste recopiée ne vieillit pas bruyamment : 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 en omettre un qui fonctionne fait perdre la vente sans que personne ne le sache.
Trois précisions qui comptent :
min_amount/max_amountbornent l'opération, pas votre compte. Vos plafonds à vous dépendent de votre niveau de vérification et se lisent dans votre console.pricingest le barème public. Si vous avez négocié, il n'apparaît pas ici : cette route ne sait pas qui l'interroge.pricingabsent veut dire qu'aucune règle ne s'applique — jamais « gratuit ».- Sur incident, la route répond 503, jamais
200 []. Gardez votre dernière réponse connue plutôt que de conclure « aucun opérateur ».
Les anciens en-têtes Valsoria-* ne sont plus envoyés. La fenêtre de compatibilité ouverte le 2026-09-07 est fermée.
| En-tête | État |
|---|---|
LibrePay-Request-Id | le seul envoyé |
LibrePay-Signature | le seul envoyé |
LibrePay-Event-Id | le seul envoyé |
Valsoria-Request-Id, Valsoria-Signature, Valsoria-Event-Id | retirés |
Le User-Agent de nos webhooks devient LibrePay-Webhooks/1.0. Le SDK n'exporte plus les alias ValsoriaPay / ValsoriaPayError : employez LibrePay et LibrePayError.
Pourquoi maintenant, et pourquoi l'annonce et le retrait le même jour. La règle que nous nous sommes donnée est qu'une suppression ne se fait pas en silence. Elle ne dit pas qu'un préavis doit courir même quand il n'y a personne à prévenir. Avant de fermer, nous avons compté en production, le 2026-09-17 au soir : aucune clé API n'y a jamais existé, aucun endpoint de webhook n'y est déclaré, aucun webhook n'y a jamais été livré. La fenêtre ne protégeait donc aucune intégration — et c'était le dernier moment où la refermer était gratuit.
Le jour où il y aura des intégrations, le préavis courra pour de bon : c'est précisément pour ne pas avoir à en négocier un plus tard que nous fermons celle-ci tant qu'elle est vide.
2026-09-07
Les en-têtes prennent le nom LibrePay — et les anciens continuent d'arriver.
La plateforme s'appelle LibrePay depuis le 2026-09-03 ; les en-têtes portaient encore l'ancienne marque. Nous envoyons désormais les deux, avec la même valeur :
| Nouveau nom | Ancien nom, toujours envoyé |
|---|---|
LibrePay-Request-Id | Valsoria-Request-Id |
LibrePay-Signature | Valsoria-Signature |
LibrePay-Event-Id | Valsoria-Event-Id |
Rien ne casse. Lisez le nouveau nom avec l'ancien en repli (req.header('LibrePay-Signature') ?? req.header('Valsoria-Signature')), et vous serez prêts le jour où les anciens partiront — ce qui n'arrivera pas sans une annonce préalable ici.
Le SDK suit : le paquet devient @librepay/pay et la classe LibrePay. ValsoriaPay et ValsoriaPayError restent exportés comme alias.
Ce qui a été corrigé dans cette documentation le même jour, parce qu'elle affirmait des choses fausses :
- le carnet de bénéficiaires s'écrit et se lit par API, et la liste pagine — la page Décaissements disait le contraire ;
- décaissements, lots, bénéficiaires et règlements existent dans l'API ; la page SDK les annonçait absents. Le SDK couvre les deux premiers ;
- 21 codes d'erreur que l'API renvoie n'étaient documentés nulle part. Ils le sont, et un contrôle automatique refuse désormais qu'un code parte sans être décrit.
2026-08-26
- Site de documentation public : vous y êtes.
- Alerte marchand quand un endpoint de webhook est désactivé, visible sur tous les écrans de la console.
- Rejeu en masse des livraisons en échec, depuis l'onglet Webhooks.
- Numéros de test documentés : dix scénarios, y compris les issues incertaines.
2026-08-25
- Spécification OpenAPI publiée, SDK JavaScript / TypeScript.
- Journal des requêtes API dans la console : route, code, latence, code d'erreur exact, identifiant de requête.
- L'en-tête de traçabilité s'appelle alors
Valsoria-Request-Id(renomméLibrePay-Request-Idle 2026-09-07 ; les deux sont envoyés).
2026-08-21
- Webhooks : signature HMAC, horodatage anti-rejeu, tentatives espacées, journal des livraisons et rejeu manuel.
- Checkout hébergé : lien de paiement pour encaisser sans intégration.
2026-08-20
- Première version : encaissement, clés API par environnement, ledger en partie double, idempotence.
Version d'API 2026-08-25 ·
changements ·
spécification OpenAPI