Webhooks
La source de vérité de vos intégrations : chaque changement d'état vous est poussé, signé, avec relivraison automatique.
Créer un endpoint
POST /v1/webhooks
{
"url": "https://votresite.com/webhook/neopays",
"description": "production",
"subscribed_events": ["payment.success", "refund.success"]
}
La réponse porte le secret de signature. subscribed_events vide = tous les événements ; un
événement hors catalogue est refusé plutôt que stocké, parce qu'il attendrait une livraison que rien
n'émet.
Cet appel garantit, il ne crée pas toujours. Une URL déjà enregistrée revient en
200au lieu d'être dupliquée — un script de provisionnement rejoué laisse UN endpoint, pas deux recevant chaque événement en double. Le secret est rendu dans les deux cas, parce que la raison légitime de rappeler est de l'avoir perdu.
GET /v1/webhooks — vos endpoints et leurs compteurs
GET /v1/webhooks/{id} — un endpoint
PATCH /v1/webhooks/{id} — modification partielle
DELETE /v1/webhooks/{id} — suppression définitive
GET /v1/webhooks/{id}/secret — relire le secret
POST /v1/webhooks/test — livraison de test vers tous vos endpoints actifs
Le PATCH ne change que ce que vous envoyez : omettre is_active ne désactive pas l'endpoint. Pour
arrêter les livraisons sans perdre l'endpoint ni son secret, envoyez is_active: false plutôt que
de supprimer.
Dix endpoints par compte au maximum. Le tableau de bord fait tout cela aussi, et lui seul sait rejouer une livraison échouée et faire une rotation de secret.
Les événements
| Événement | Quand |
|---|---|
payment.initiated |
Encaissement créé |
payment.pending |
En attente de confirmation du payeur |
payment.success |
Encaissé — l'événement qui fait foi |
payment.failed |
Échec |
payout.success / payout.failed / payout.cancelled |
Cycle de vie d'un versement |
payout.refunded |
Versement retourné par le rail après coup |
refund.success / refund.failed |
Cycle de vie d'un remboursement |
L'enveloppe est toujours la même :
{
"event": "payment.success",
"created": "2026-08-24T12:00:00Z",
"data": {
"id": "op_01J8Z9K2QW",
"reference": "NP-...",
"merchant_reference": "commande-1042",
"amount": "5000",
"currency": "XOF",
"status": "success"
}
}
Le corps du webhook n'a pas la même forme que la réponse de l'API. Il porte
amountetcurrencylà où l'API porteamount_minoretasset_id, etmerchant_referencelà où elle porteclient_ref. C'est un contrat distinct, écrit avant l'autre et inchangé depuis : ne supposez pas qu'un objet de l'une se décode comme un objet de l'autre.
Vérifier la signature
Chaque livraison porte l'en-tête :
X-NeoPays-Signature: t=<horodatage unix>,v1=<hmac>
où hmac = HMAC-SHA256(secret, "<t>.<corps brut>"), en hexadécimal. Vérifiez en trois temps :
const crypto = require('node:crypto')
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
Le corps BRUT, pas le corps re-sérialisé. Calculez le HMAC sur les octets reçus, avant tout parsing JSON. Un re-
JSON.stringifypeut réordonner les clés et invalider la signature.
Pendant une rotation de secret, l'en-tête porte DEUX entrées
v1=, le secret courant en premier. Vérifiez-les toutes, pas seulement la première : le jour où vous basculez votre configuration sur le nouveau secret avant la fin de la bascule plateforme, la première signature est celle de l'ancien secret, et un vérificateur qui s'arrête à la première rejette alors 100 % des livraisons.
Relivraison
Répondez 2xx en moins de quelques secondes (accusez réception, traitez ensuite). Toute autre
réponse déclenche l'échelle de rejeu : 5 tentatives au total, après 60 s, 5 min, 30 min puis
2 h.
Un endpoint qui échoue 20 livraisons consécutives est suspendu une heure (les livraisons reprennent seules) ; un seul succès — y compris une livraison de test depuis le tableau de bord — le réhabilite immédiatement.
Idempotence côté récepteur
Les livraisons sont at-least-once : le même événement peut arriver deux fois. Dédupliquez sur le
couple (event, data.id) ou journalisez les identifiants d'événements traités.