Webhooks
Le webhook est la seule façon fiable d'apprendre qu'un paiement a abouti. Tout le reste — interroger en boucle, faire confiance au 201 — vous fera livrer trop tôt ou trop tard.
Déclarer un endpoint
Depuis la console, onglet Webhooks, ou par l'API (/v1-webhook-endpoints, ci-dessous). Un secret de signature whsec_… s'affiche une seule fois.
Un endpoint appartient à un mode. Celui que vous déclarez en mode test ne reçoit que les événements de test ; celui de production, que les événements réels. Le mode se choisit à la création et ne change plus : pour passer en production, déclarez un second endpoint depuis la console en mode production, ou avec votre clé vp_sk_live_.
Vous choisissez ce que vous recevez : tous les événements (y compris ceux que nous ajouterons plus tard), ou une sélection. La liste à jour : GET /v1-webhook-endpoints/event-types.
L'adresse doit être en HTTPS et joignable depuis Internet : une adresse privée (10.x, 192.168.x, localhost…) est refusée.
Tout ce qui se règle
| Geste | Console | API |
|---|---|---|
| Déclarer | « Ajouter un endpoint » | POST /v1-webhook-endpoints |
| Modifier l'adresse, la description, les événements | « Modifier » | POST /v1-webhook-endpoints/{id} |
| Éprouver l'endpoint | « Envoyer un test » | POST /v1-webhook-endpoints/{id}/test |
| Changer le secret | « Nouveau secret » | POST /v1-webhook-endpoints/{id}/rotate-secret |
| Couper / rétablir | « Désactiver » / « Réactiver » | POST /v1-webhook-endpoints/{id}/disable · /enable |
L'événement de test (webhook.test) part vers cet endpoint seul, par le même chemin que les vrais : même signature, mêmes réessais. Aucun argent ne bouge. C'est la façon de vérifier que votre serveur reçoit et vérifie la signature avant votre premier paiement.
Changer le secret le remplace immédiatement : l'ancien ne signe plus rien. Mettez le nouveau en place chez vous aussitôt ; les livraisons refusées entre-temps se rejouent.
Par l'API, une clé ne gère que les événements de son service : la clé d'encaissement les payment.*, celle de décaissement les payout.* et payout_request.*, celle d'IBAN les iban.* et topup.*. Une clé de site web est la plus exposée : elle ne doit pas pouvoir abonner une adresse de son choix à vos décaissements. « Tous les événements » se règle depuis la console.
Pour déclarer un endpoint, envoyez en POST sur /v1-webhook-endpoints :
{ "url": "https://votre-serveur.ci/webhooks/librepay",
"enabled_events": ["payment.succeeded", "payment.failed"],
"description": "Serveur de commandes" }
La réponse porte secret — une seule fois. Pour relire vos endpoints :
curl "$LIBREPAY_BASE/v1-webhook-endpoints" \
-H "Authorization: Bearer $LIBREPAY_CLE"
{"object": "list", "data": […]}
Vérifier la signature
Chaque appel porte :
LibrePay-Signature: t=1755770000,v1=<hmac-sha256 hexadécimal>
LibrePay-Event-Id: evt_...
User-Agent: LibrePay-Webhooks/1.0
La signature porte sur t + "." + corps, pas sur le corps seul : sans l'horodatage dans le message signé, un ancien appel authentique pourrait être rejoué en changeant simplement le t affiché.
import { LibrePay } from '@librepay/pay';
const evenement = await LibrePay.webhooks.construireEvenement({
corpsBrut: req.body.toString('utf8'),
signature: req.header('LibrePay-Signature'),
secret: process.env.LIBREPAY_WEBHOOK_SECRET,
});
Les trois règles, par ordre d'importance
1. Vérifiez la signature avant de lire le contenu. Votre endpoint est une URL publique. Sans vérification, n'importe qui vous annonce un paiement réussi — et vous livrez.
2. Signez sur le corps BRUT. Un corps re-sérialisé après JSON.parse change d'un octet — ordre des clés, espaces — et la signature porte sur les octets reçus. Avec Express, express.raw({ type: 'application/json' }), jamais express.json. C'est de loin la première cause d'échec de vérification.
3. Dédupliquez sur id. La livraison est « au moins une fois » : un même événement peut arriver deux fois, un simple retard réseau suffit.
Répondre
Répondez 2xx dès que l'événement est enregistré, pas quand il est traité. Un traitement long derrière un webhook finit en délai dépassé, donc en nouvelle tentative, donc en doublon.
| Votre réponse | Ce que nous faisons |
|---|---|
2xx | Terminé. |
410 Gone | Terminé : vous déclarez que l'endpoint n'existe plus. |
| Autre code, ou rien | Nouvelle tentative, avec un délai croissant. |
Quand ça ne répond plus
Après épuisement des tentatives, la livraison est abandonnée. Au bout de plusieurs abandons, l'endpoint est désactivé — et une alerte apparaît dans votre console, sur tous les écrans.
Nous ne vous prévenons pas par webhook, pour une raison évidente : l'événement partirait vers l'endpoint qui vient d'être désactivé.
Une fois l'adresse corrigée et réactivée, le bouton Tout rejouer relance en une fois toutes les livraisons en échec. Seuls les couples (événement, endpoint) dont la dernière tentative a échoué repartent : cliquer deux fois ne livre pas deux fois.
Les événements émis
| Type | Quand |
|---|---|
payment.action_required | Votre client doit aller quelque part pour payer. Émis uniquement quand l'opérateur héberge sa propre page (Wave) : la charge porte redirect_url. Il ne conclut rien. |
payment.succeeded | L'opérateur a confirmé. C'est le seul qui dit que l'argent est là. |
payment.failed | Refus établi. |
payment.refunded · payment.partially_refunded | Tout ou partie du montant a été rendu au payeur. |
payout.pending | Le décaissement est transmis à l'opérateur. |
payout.succeeded | Le bénéficiaire a reçu les fonds. |
payout.failed | L'envoi a échoué — et le montant a été rendu au marchand, commission comprise. La charge porte reverted: true. |
payout_request.approved | Une demande retenue par un plafond a été approuvée dans votre console : le décaissement est créé, son identifiant est dans payout_id (txn_…) — c'est lui que porteront les payout.* suivants. |
payout_request.refused | La demande retenue a été refusée en console (refusal_reason). Rien n'est parti. |
payout_request.expired | La demande retenue n'a pas été tranchée à temps (48 h). Rien n'est parti : si le décaissement est toujours voulu, refaites l'appel. |
iban.created | Un IBAN de collecte vient d'être ouvert à votre nom. |
topup.succeeded | Un virement bancaire est arrivé sur votre IBAN de collecte, et votre solde de transfert en a été crédité. Personne ne l'a demandé : c'est un constat, pas une réponse à un appel. |
La charge d'un payout.*
C'est exactement l'objet que rend GET /v1/payouts/{id} — mêmes champs, mêmes valeurs. Relevé en sandbox, pas rédigé :
{
"type": "payout.succeeded",
"data": {
"id": "txn_2t_6nNeQVqWj_j3fBYFVSw",
"amount": 10000,
"object": "payout",
"status": "succeeded",
"currency": "XOF",
"livemode": false,
"reference": null,
"created_at": "2026-09-24T10:54:38.413183+00:00",
"fee_amount": 140,
"beneficiary": {
"name": "Ben 0003",
"label": "Ben 0003",
"msisdn": "+2250700000003"
},
"failure_code": null,
"total_debited": 10140
}
}
id est l'identifiant public du décaissement, celui que POST /v1/payouts vous a rendu. C'est par lui que vous rapprochez.
total_debited vaut amount + fee_amount : ce qui sort de votre solde de transfert. Il n'y a pas de net_amount ni d'amount_collected — ces deux champs disent « ce que le marchand reçoit » et n'ont de sens qu'à l'encaissement ; sur un décaissement ils vaudraient le montant envoyé et se liraient comme une recette.
⚠ Corrigé le 2026-09-24. Jusqu'à cette date, la charge d'un payout.* ne portait que { id, amount } — et cet id était notre identifiant interne, que vous n'avez jamais vu. Le webhook partait, il était inrapprochable. Mesuré avant correction : 1 480 payout.succeeded, pas un seul exploitable par son destinataire.
⚠ fee_amount SUR UN payout.failed N'EST PAS UN COÛT. Un échec rend le montant commission comprise : la charge porte alors reverted: true, et les frais annoncés vous ont été rendus. Les comptabiliser comme une dépense créerait une charge qui n'existe pas. La règle : si reverted est vrai, l'opération vous a coûté zéro, quel que soit fee_amount.
topup.succeeded — le seul que vous n'avez pas provoqué
Tous les autres événements répondent à quelque chose que vous avez initié : un paiement, un décaissement. Celui-ci non. Il vous dit qu'un tiers a viré de l'argent sur votre IBAN de collecte — vous n'en connaissiez ni le moment ni le montant.
⚠ Il peut arriver longtemps après le virement. La banque ne nous prévient pas : nous relisons son relevé toutes les cinq minutes. Un virement passé en compensation la veille au soir sera constaté le lendemain, avec sa vraie date d'opération dans recu_le — qui n'est donc pas l'heure de l'événement.
La charge porte le montant, la devise, la référence bancaire, la date d'opération, l'IBAN crédité, et l'origine telle que la banque la nomme.
{
"type": "topup.succeeded",
"data": {
"topup_id": "…", "montant": 500000, "devise": "XOF",
"reference": "030626038387", "recu_le": "2026-06-03",
"iban": "CI93CI2010100112727348803927",
"iban_id": "…", "intitule": "VALSORIA IMPEX CI",
"origine": { "banque": "Banque diverse", "libelle": "VIRMT FAV …" }
}
}
⚠ Un virement que nous ne savons pas rattacher ne produit AUCUN événement. S'il arrive sur un compte que nous ne connaissons pas, ou dans une devise autre que le franc CFA, il est constaté de notre côté et laissé à l'examen d'un opérateur. Ne construisez donc pas une comptabilité qui suppose « tout virement reçu = un événement » : supposez l'inverse, et rapprochez.
payout.failed ne veut pas dire « argent perdu ». Un décaissement débite le solde de transfert au moment de la demande ; si l'envoi échoue, une écriture inverse le restitue en entier. Le champ reverted est là pour que vous n'ayez pas à le deviner — ni à ouvrir un incident pour une somme qui est déjà revenue.
⚠ payment.pending n'existe pas, malgré ce que cette page a affirmé jusqu'au 2026-09-21. Un encaissement parti chez l'opérateur n'émet rien : la transaction passe à pending, et le prochain événement est succeeded ou failed. Si vous attendiez payment.pending, vous attendiez pour rien — c'est corrigé ici, pas dans le code : nous ne créons pas un événement pour justifier une phrase.
Il n'existe aucun événement pour une issue incertaine. Quand l'opérateur ne conclut pas, la transaction reste pending et nous vérifions auprès de lui. Vous ne recevrez succeeded ou failed qu'une fois la réponse obtenue — jamais une supposition.
La charge utile
{
"id": "evt_9xK2...",
"object": "event",
"type": "payment.succeeded",
"api_version": "2026-08-25",
"created_at": "2026-08-26T09:13:02.114Z",
"livemode": false,
"data": { }
}
Version d'API 2026-08-25 ·
changements ·
spécification OpenAPI