Recevoir les notifications (webhooks)
Un webhook est un appel que MafPay fait vers votre serveur dès qu’un paiement change de statut (payé, échoué, expiré…). C’est la méthode recommandée pour savoir qu’un paiement est réussi : vous n’avez rien à interroger.
MafPay ───── POST https://maboutique.sn/mafpay/webhook ─────► votre serveur { "event": "transaction.success", … } (vous répondez 200)1. Configurer l’adresse de notification
Dans le portail : Applications → votre application → réglages de notification. Renseignez :
| Réglage | Rôle |
|---|---|
webhook_url |
L’adresse de votre serveur qui reçoit les notifications, ex : https://maboutique.sn/mafpay/webhook. |
callback_url_success |
Où renvoyer le client après un paiement réussi (utilisé si success_url n’est pas donné à l’initiation). |
callback_url_failure |
Où renvoyer le client après un échec (utilisé si error_url n’est pas donné). |
Règles pour webhook_url :
- HTTPS obligatoire en production ;
- l’adresse doit être publique : MafPay n’appelle jamais une adresse privée ou locale (
localhost,192.168.x.x,10.x.x.x…). Pour tester sur votre ordinateur, exposez-le avec un outil comme ngrok ; - c’est la même adresse en sandbox et en live : le champ
environmentde chaque notification vous dit de quel mode elle vient.
2. Le contenu d’une notification
MafPay envoie une requête POST avec un corps JSON :
{ "id": "MAFPAY-W7VOB6J6:SUCCESS", "event": "transaction.success", "environment": "sandbox", "created_at": "2026-09-25T13:34:17.700000Z", "data": { "transaction_uuid": "dcea6fb6-4786-456f-85cc-0450bdccc0a7", "reference": "MAFPAY-W7VOB6J6", "provider": "WAVE_SN", "environment": "sandbox", "amount": "5000.00", "currency": "XOF", "status": "SUCCESS", "provider_reference": "cos-sandbox-efbbcc6513fe489d9b04", "paid_at": "2026-09-25T13:34:17.612928Z", "expired_at": null, "error_message": null, "metadata": { "order_id": 123 } }}| Champ | Signification |
|---|---|
id |
Identifiant stable de la notification (référence:STATUT). Sert à ignorer les doublons. |
event |
Ce qui s’est passé (voir ci-dessous). |
environment |
sandbox ou live. |
data |
Le paiement, avec les mêmes champs que le statut, et vos metadata (c’est là que vous retrouvez order_id). |
Les événements
event |
Quand |
|---|---|
transaction.success |
Le paiement est réussi : vous pouvez livrer. |
transaction.failed |
Le paiement a échoué (data.error_message donne le motif). |
transaction.cancelled |
Le client a annulé. |
transaction.expired |
Le délai est dépassé. |
transaction.refunded |
Le paiement a été remboursé. |
Les en-têtes envoyés
| En-tête | Contenu |
|---|---|
X-MafPay-Event |
Le nom de l’événement (ex : transaction.success). |
X-MafPay-Signature |
La signature : t=1758807257,v1=9b1f…. Voir ci-dessous. |
User-Agent |
MafPay-Webhook/1.0 |
Content-Type |
application/json |
3. Vérifier la signature (obligatoire)
Votre adresse de webhook est publique : n’importe qui pourrait y envoyer une fausse notification « payé ». La signature prouve que la notification vient bien de MafPay et n’a pas été modifiée.
Comment elle est calculée : HMAC-SHA256 du texte "<t>." + <corps brut> avec, comme clé, le secret de votre application pour l’environnement du paiement (sk_test_… pour un paiement sandbox, sk_live_… pour un paiement live). t est l’horodatage (en secondes) contenu dans l’en-tête.
Ce que vous devez faire :
- Lire l’en-tête
X-MafPay-Signatureet en extrairetetv1. - Recalculer
HMAC-SHA256(secret, t + "." + corps_brut)en hexadécimal. - Comparer avec
v1en temps constant (pas avec==). - Refuser si l’horodatage
ta plus de 5 minutes (protection contre le rejeu d’une vieille notification).
import crypto from "node:crypto";import express from "express";
const app = express();const SECRET = process.env.MAFPAY_SK; // sk_test_… en sandbox, sk_live_… en production
export function verifyMafPaySignature(rawBody, header, secret, toleranceSeconds = 300) { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const timestamp = parts.t; const received = parts.v1; if (!timestamp || !received) return false;
// rejeu : on refuse une notification trop ancienne if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(received); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// express.raw : on garde le corps BRUT (Buffer) pour vérifier la signatureapp.post("/mafpay/webhook", express.raw({ type: "application/json" }), async (req, res) => { if (!verifyMafPaySignature(req.body, req.get("X-MafPay-Signature"), SECRET)) { return res.status(401).send("signature invalide"); }
const event = JSON.parse(req.body.toString("utf8"));
// 1. ignorer les doublons (MafPay peut renvoyer la même notification) if (await alreadyProcessed(event.id)) return res.sendStatus(200);
// 2. agir selon l'événement if (event.event === "transaction.success") { await markOrderPaid(event.data.metadata.order_id, event.data.reference); } else if (["transaction.failed", "transaction.cancelled", "transaction.expired"].includes(event.event)) { await markOrderNotPaid(event.data.metadata.order_id, event.data.status); }
await rememberProcessed(event.id); res.sendStatus(200); // répondre vite : 2xx = « bien reçu »});
app.listen(3000);import hashlibimport hmacimport osimport time
from flask import Flask, request
app = Flask(__name__)SECRET = os.environ["MAFPAY_SK"] # sk_test_… en sandbox, sk_live_… en production
def verify_mafpay_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: if not header: return False parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) timestamp, received = parts.get("t"), parts.get("v1") if not timestamp or not received: return False if abs(time.time() - int(timestamp)) > tolerance: # rejeu return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received)
@app.post("/mafpay/webhook")def mafpay_webhook(): raw = request.get_data() # corps BRUT, avant tout json.loads if not verify_mafpay_signature(raw, request.headers.get("X-MafPay-Signature", ""), SECRET): return "signature invalide", 401
event = request.get_json(force=True) if already_processed(event["id"]): # doublon return "", 200
if event["event"] == "transaction.success": mark_order_paid(event["data"]["metadata"]["order_id"], event["data"]["reference"]) elif event["event"] in ("transaction.failed", "transaction.cancelled", "transaction.expired"): mark_order_not_paid(event["data"]["metadata"]["order_id"], event["data"]["status"])
remember_processed(event["id"]) return "", 200<?php$secret = getenv('MAFPAY_SK'); // sk_test_… en sandbox, sk_live_… en production
function verifyMafPaySignature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool { if (!$header) return false; $parts = []; foreach (explode(',', $header) as $part) { [$k, $v] = array_pad(explode('=', $part, 2), 2, null); $parts[$k] = $v; } if (empty($parts['t']) || empty($parts['v1'])) return false; if (abs(time() - (int)$parts['t']) > $tolerance) return false; // rejeu $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}
$raw = file_get_contents('php://input'); // corps BRUTif (!verifyMafPaySignature($raw, $_SERVER['HTTP_X_MAFPAY_SIGNATURE'] ?? null, $secret)) { http_response_code(401); exit('signature invalide');}
$event = json_decode($raw, true);if (alreadyProcessed($event['id'])) { http_response_code(200); exit; }
if ($event['event'] === 'transaction.success') { markOrderPaid($event['data']['metadata']['order_id'], $event['data']['reference']);} elseif (in_array($event['event'], ['transaction.failed', 'transaction.cancelled', 'transaction.expired'], true)) { markOrderNotPaid($event['data']['metadata']['order_id'], $event['data']['status']);}
rememberProcessed($event['id']);http_response_code(200);(alreadyProcessed, markOrderPaid, markOrderNotPaid et rememberProcessed sont vos propres fonctions de base de données.)
4. Répondre correctement
| Votre réponse | Ce que fait MafPay |
|---|---|
2xx (200, 201, 204…) |
« Bien reçu » : c’est terminé. |
429, 5xx (erreur serveur), serveur injoignable, délai dépassé |
Nouvelle tentative : jusqu’à 3 nouveaux essais, à 1 minute d’intervalle, puis MafPay abandonne. |
Autre 4xx (400, 401, 404…) |
Abandon immédiat (MafPay considère que vous refusez la notification). |
| Redirection (301, 302…) | Jamais suivie : donnez directement l’adresse finale. |
Vous avez 15 secondes pour répondre. Répondez vite (200) et faites le travail long ensuite, ou dans une file de tâches.
5. Bonnes pratiques
- Toujours vérifier la signature, sur le corps brut, en temps constant.
- Être idempotent : une même notification peut arriver plusieurs fois (nouvelles tentatives). Gardez l’
id(ou lareference+ le statut) déjà traité et ignorez les doublons, pour ne pas livrer deux fois. - Ne faites pas confiance au contenu sans le recouper : au besoin, confirmez avec
GET …/status/avant de livrer un gros montant. - Retrouvez votre commande via
metadata(ex :order_id), pas en devinant. - Comparez le montant (
data.amount) à celui de votre commande avant de valider. - Ne dépendez pas de l’ordre : deux notifications rapprochées peuvent arriver dans un ordre inattendu ; MafPay ne notifie que des changements réels de statut.
- Journalisez ce que vous recevez (sans y écrire vos secrets) : c’est précieux pour comprendre un incident.
- Si votre serveur était en panne et que vous avez raté une notification, interrogez le statut du paiement pour vous remettre à jour.