Webhooks
Recevoir les événements Mobupay en temps réel.
Format d'un événement
Mobupay envoie une requête POST à votre endpoint avec un body JSON :
{
"id": "whe_2NjK8mLk0093xY",
"type": "payment.authorized",
"createdAt": "2026-05-14T12:34:56Z",
"data": {
"paymentId": "pay_xxxxxxxx",
"amount": 1000,
"currency": "XPF",
"status": "transit",
"reference": "SPR-xxxxxxxx",
"customerId": "cus_xxxxxxxx",
"customerCreated": true,
"paymentMethodId": "pm_xxxxxxxx",
"cardBrand": "CB",
"cardLast4": "0007",
"store": { "id": "sto_7Kq2mZp4nT8xB1", "code": "PAITA", "name": "Boutique de Païta" }
}
}Quand un client est créé pendant le paiement (paramètre saveCustomer), le champ customerId contient l'identifiant créé et customerCreated vaut true. Si une carte est enregistrée au passage, paymentMethodId est fourni.
customerId est présent sur tous les événements payment.authorized d'un paiement rattaché à un client, créé à l'instant ou non. Stockez-le sur votre utilisateur : c'est lui qu'il faudra renvoyer à la création des sessions suivantes pour que le client retrouve ses cartes enregistrées. Voir le guide Cartes enregistrées.
Les événements payment.* et refund.* portent la boutique d'encaissement dans store, sous la forme { id, code, name }. Le code y figure, pas seulement l'identifiant : c'est lui que vos systèmes reconnaissent, et il suffit à ventiler l'encaissement sans rappeler l'API. Le champ vaut null quand le paiement n'est rattaché à aucune boutique. Voir le guide Plusieurs boutiques. Les événements de facturation et de compte n'en portent pas : une facture ou un client ne se rattache pas à un point de vente.
Répondez 2xx dans les 10 secondes pour acquitter la réception. Tout autre code (4xx/5xx) ou timeout déclenchera un retry.
Détail des commissions dans l'événement
Les événements payment.authorized, payment.captured, payment.refunded, payment.partially_refunded et payment.cancelled portent un bloc settlement : la commission Mobupay, le net, et la composition ligne par ligne. Vous n'avez pas besoin de rappeler l'API pour savoir ce qui vous a été facturé.
"settlement": {
"capturedAmount": 9479,
"mobupayCommission": 165,
"netAmount": 9314,
"currency": "XPF",
"settled": false,
"detailedFees": [
{
"id": "spirit_commission",
"label": "Commission sur encaissement",
"type": "PERCENTAGE",
"rate": 140,
"amount": 132, "amountEur": 111,
"taxAmount": 8, "taxAmountEur": 7,
"taxes": [{ "id": "TGC6", "type": "PERCENTAGE", "value": 600, "amount": 8, "amountEur": 7 }],
"totalAmount": 141, "totalAmountEur": 118
},
{ "id": "spirit_fixed_fee", "label": "Frais fixe par transaction", "totalAmount": 16, "totalAmountEur": 13 },
{ "id": "authorization_result", "label": "Autorisation (capture différée)", "totalAmount": 8, "totalAmountEur": 7 }
]
}Les montants sont dans la devise d'origine du paiement ; les champs amountEur portent la valeur exacte en centimes d'euro. La grille tarifaire étant en euros, chaque ligne est convertie puis arrondie au franc : la somme des totalAmount peut donc dépasser mobupayCommission d'un franc. Pour un rapprochement comptable exact, additionnez les totalAmountEur.
settled: false signifie que le règlement de l'acquéreur n'est pas encore parvenu : les lignes décrivent la facturation engagée. Les frais déjà prélevés ne sont pas restitués par un remboursement, qui ajoute sa propre ligne. Sur un paiement de place de marché, chaque entrée de platformDistribution porte de la même façon son detailedCommission.
Liste des événements
| Événement | Description |
|---|---|
| payment.authorized | La carte du client a été autorisée (avant capture si MANUAL). |
| payment.captured | Le paiement est encaissé. La commission annoncée est une projection. |
| payment.settled | Les fonds sont reçus et la commission Mobupay est définitive. |
| payment.failed | Le paiement a échoué (refus carte). |
| payment.expired | La session / le lien de paiement a expiré sans paiement. |
| payment.refunded | Un remboursement total ou partiel a été effectué. |
| payment.partially_refunded | Un remboursement partiel a été effectué. |
| payment.cancelled | Un paiement autorisé non capturé a été annulé. |
Événements de facturation
Émis par le module Facturation, pour les factures dont vous êtes l'émetteur. Ils évitent d'interroger l'API en boucle pour savoir si une facture a été réglée.
La charge utile porte les identifiants, le statut, les montants et le reste dû, mais jamais les lignes ni l'identité de votre client : ces données ne traversent pas le réseau sans nécessité. Pour le détail complet, appelez GET /billing/invoices/{id}, qui sert toujours l'état courant, alors qu'un webhook peut arriver dans le désordre.
| Événement | Description |
|---|---|
| invoice.issued | Une facture a été émise : elle porte son numéro définitif et son contenu est figé. |
| invoice.sent | La facture a été transmise à son destinataire, avec son lien de règlement. |
| invoice.paid | La facture est intégralement réglée. |
| invoice.partially_paid | Un règlement partiel a été imputé ; le reste dû figure dans la charge utile. |
| invoice.overdue | L'échéance est dépassée sans règlement complet. |
| invoice.disputed | Un règlement encaissé a été contesté : la facture est rouverte en litige. |
| invoice.credited | La facture a été annulée par un avoir du montant total. |
| invoice.uncollectible | La créance a été déclarée irrécouvrable. |
Événements d'abonnement
Si vous prélevez des abonnements, ces événements suivent leur cycle de vie. Le plus important est subscription.mandate_revoked : c'est le seul avertissement qu'un prélèvement s'est arrêté faute de carte, et il vous parvient au moment où cela se produit, non à l'échéance suivante.
| Événement | Description |
|---|---|
| subscription.created | Un abonnement a été créé. Sans carte rattachée, il attend son mandat et ne prélève rien. |
| subscription.payment_succeeded | Une échéance a été prélevée. La charge utile porte l'échéance et le paiement. |
| subscription.payment_failed | Une échéance a été refusée. Une nouvelle tentative est programmée selon votre cadence tant qu'il en reste. |
| subscription.past_due | Les tentatives sont épuisées : l'échéance devient un impayé, et la relance repasse au module Facturation si une facture y est liée. |
| subscription.mandate_revoked | La carte ne peut plus être prélevée (supprimée par le porteur ou par vous, désactivée, ou introuvable chez l'acquéreur). L'abonnement repasse en attente de mandat et cesse d'ouvrir des échéances. `reason` dit lequel de ces cas s'est produit. C'est l'événement à écouter pour redemander une carte à votre client. |
| subscription.mandate_restored | Une carte a été rattachée et l'abonnement reprend à sa prochaine échéance. `reason` dit qui l'a rattachée : vous, votre intégration, ou le porteur depuis son portefeuille. |
| subscription.cancelled | L'abonnement est résilié. C'est définitif : l'échéancier ne reprend pas. |
Événements de compte
Le cycle de vie de vos clients, de leurs cartes enregistrées, de vos virements et de vos litiges. Ce sont les événements qui surviennent sans que vous les ayez déclenchés : une carte que la banque du porteur cesse de reconnaître, un virement rejeté par l'interbancaire plusieurs jours après son départ, une contestation.
La charge utile est complète : elle porte l'objet tel que vous le connaissez, pour que vous puissiez la recopier sans rappeler l'API. Deux réserves, toutes deux volontaires. Un virement ne porte jamais les coordonnées bancaires du bénéficiaire, seulement son recipientId : une charge utile est poussée vers votre URL et finit dans vos journaux. Un litige ne porte pas la charge brute reçue de notre partenaire bancaire, qui contient des données du porteur de carte.
| Événement | Description |
|---|---|
| customer.created | Une fiche client a été créée, par l'API ou par un paiement portant l'adresse de l'acheteur. |
| customer.updated | Une fiche client a changé. Le champ `updatedFields` dit quoi, pour vous éviter de tout comparer. |
| payment_method.added | Une carte a été enregistrée pour un client, par l'API ou à l'occasion d'un paiement. |
| payment_method.removed | Une carte ne peut plus servir. Le champ `reason` dit pourquoi : `deleted`, `disabled`, ou `unknown_at_acquirer` quand la banque du porteur ne la reconnaît plus. |
| payout.created | Un virement vers votre compte bancaire a été initié, manuellement ou par votre calendrier de reversement. |
| payout.completed | Le virement a été exécuté. |
| payout.failed | Le virement a échoué, a été rejeté par l'interbancaire ou annulé. Le champ `failureReason` porte le motif. |
| dispute.created | Une contestation ou un impayé est arrivé sur un de vos paiements. |
| dispute.updated | Le statut d'un litige a évolué. `previousStatus` donne l'état précédent. |
Événements plateforme
Si vous êtes une plateforme (marketplace), abonnez-vous à ces événements pour suivre le cycle de vie de vos sous-marchands affiliés : invitation, onboarding KYB, ouverture du compte de paiement et rattachement.
| Événement | Description |
|---|---|
| merchant.session.created | Une invitation a été émise pour un sous-marchand. |
| merchant.session.expired | Un lien d'invitation a expiré sans qu'aucun dossier n'ait été ouvert. Réémettez une invitation. |
| merchant.session.cancelled | L'invitation d'un sous-marchand a été annulée. |
| merchant.onboarding.updated | L'état d'enrôlement d'un sous-marchand a changé. C'est l'événement à écouter pour suivre un dossier pas à pas : les autres n'en donnent que les bornes. |
| merchant.created | Le compte du sous-marchand a été créé et rattaché à votre plateforme. |
| merchant.opened | Le compte de paiement du sous-marchand a été ouvert (KYB validé, prêt à encaisser). |
| merchant.rejected | Une décision défavorable est intervenue. Lisez `enrollmentStatus` pour savoir laquelle : `CORRECTION_REQUESTED` (des corrections sont attendues, le dossier vit toujours) ou `REFUSED` (fin de parcours). |
| merchant.linked | Un marchand existant a été rattaché à votre plateforme. |
| merchant.unlinked | Un sous-marchand a été détaché de votre plateforme. Son compte, lui, reste ouvert. |
Vérification de signature
Chaque requête webhook contient un header X-Mobupay-Signature avec une signature HMAC-SHA256 du body. Vérifiez-la avant de traiter l'événement pour vous protéger des requêtes forgées.
Exemple Node.js :
import crypto from "node:crypto";
const SECRET = process.env.MOBUPAY_WEBHOOK_SECRET;
function verifySignature(rawBody, signature) {
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
app.post("/webhook", (req, res) => {
const signature = req.headers["x-mobupay-signature"];
if (!verifySignature(req.rawBody, signature)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.rawBody);
// ... traiter l'event
res.sendStatus(200);
});Important : utilisez le body brut (non-parsé) pour calculer la signature. Un body re-serialisé par JSON.stringify peut produire un hash différent.
Signature V2 (anti-rejeu, recommandée)
En plus de X-Mobupay-Signature (V1, HMAC du corps), chaque livraison porte X-Mobupay-Timestamp (epoch secondes) et X-Mobupay-Signature-V2 = HMAC-SHA256 de {timestamp}.{corps}. Le timestamp étant signé, une requête interceptée ne peut pas être rejouée plus tard : rejetez-la si l'horodatage dépasse votre fenêtre de tolérance (5 min recommandé).
import crypto from "node:crypto";
function verifyV2(rawBody, headers, secret, toleranceSec = 300) {
const ts = headers["x-mobupay-timestamp"];
const sig = headers["x-mobupay-signature-v2"];
if (!ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // anti-rejeu
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}V1 reste émise pour compatibilité. Les nouveaux intégrateurs (connecteurs e-commerce) doivent vérifier V2 + la fraîcheur du timestamp.
Politique de retry
Si votre endpoint ne répond pas 2xx dans les 10 secondes, Mobupay retry :
- 1ère retry : après 1 min
- 2ème : 5 min
- 3ème : 30 min
- 4ème : 2 h
- 5ème : 12 h
- Après 5 échecs : abandon (votre endpoint est considéré comme cassé). Vous pouvez voir l'historique dans le BO.
Idempotence côté receiver
À cause des retry, un même événement peut être livré plusieurs fois. Utilisez le champ id (préfixe evt_) pour dédupliquer côté serveur : stockez les IDs déjà traités et ignorez les doublons.