Aller au contenu

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 environment de 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 :

  1. Lire l’en-tête X-MafPay-Signature et en extraire t et v1.
  2. Recalculer HMAC-SHA256(secret, t + "." + corps_brut) en hexadécimal.
  3. Comparer avec v1 en temps constant (pas avec ==).
  4. Refuser si l’horodatage t a 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 signature
app.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 hashlib
import hmac
import os
import 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
webhook.php
<?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 BRUT
if (!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

  1. Toujours vérifier la signature, sur le corps brut, en temps constant.
  2. Être idempotent : une même notification peut arriver plusieurs fois (nouvelles tentatives). Gardez l’id (ou la reference + le statut) déjà traité et ignorez les doublons, pour ne pas livrer deux fois.
  3. Ne faites pas confiance au contenu sans le recouper : au besoin, confirmez avec GET …/status/ avant de livrer un gros montant.
  4. Retrouvez votre commande via metadata (ex : order_id), pas en devinant.
  5. Comparez le montant (data.amount) à celui de votre commande avant de valider.
  6. 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.
  7. Journalisez ce que vous recevez (sans y écrire vos secrets) : c’est précieux pour comprendre un incident.
  8. Si votre serveur était en panne et que vous avez raté une notification, interrogez le statut du paiement pour vous remettre à jour.