Guides
Webhooks
Un webhook est un appel HTTP que MivaaPay fait vers votre serveur quand un paiement ou un remboursement change d'état. C'est le moyen le plus fiable — et le moins coûteux — de savoir qu'un client vous a payé.
Configurer votre endpoint
Déclarez une URL HTTPS accessible publiquement. Vous pouvez le faire depuis le tableau de bord, ou par l'API :
La configuration porte sur le mode de la clé employée. Une clé
sk_test_… configure l'endpoint du bac à sable et ne
peut pas modifier celui de production — ni son secret. Prévoyez donc deux URL et deux secrets.
curl -X PUT https://api.mivaapay.com/api/v1/webhooks/endpoint \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://boutique.example.com/webhooks/mivaapay" }'
$endpoint = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
->put('https://api.mivaapay.com/api/v1/webhooks/endpoint', [
'url' => 'https://boutique.example.com/webhooks/mivaapay',
])
->json('data');
// $endpoint['secret'] : à stocker, il sert à vérifier les signatures
const res = await fetch('https://api.mivaapay.com/api/v1/webhooks/endpoint', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: 'https://boutique.example.com/webhooks/mivaapay' })
});
const { data } = await res.json();
endpoint = requests.put(
'https://api.mivaapay.com/api/v1/webhooks/endpoint',
json={'url': 'https://boutique.example.com/webhooks/mivaapay'},
headers={'Authorization': f"Bearer {SECRET_KEY}"},
timeout=30,
).json()['data']
{
"success": true,
"data": {
"url": "https://boutique.example.com/webhooks/mivaapay",
"secret": "whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
Le secret est ce qui vous permet de prouver qu'un appel vient bien de MivaaPay.
Stockez-le comme un mot de passe. Vous pouvez le relire à tout moment avec
GET /webhooks/endpoint, et le régénérer en envoyant
"rotate_secret": true.
Un endpoint par paiement
Le champ callback_url de POST /payments a la priorité sur l'endpoint
global du compte, pour ce paiement seulement. Utile pour router les notifications d'une boutique
ou d'un environnement particulier sans toucher à la configuration globale.
Désactiver
Envoyez {"url": null}. Aucune notification ne partira plus ; l'API reste interrogeable.
Événements
| Événement | Déclencheur | Contenu de data |
|---|---|---|
payment.succeeded |
Le client a été débité. | Objet paiement |
payment.failed |
L'opérateur a refusé le paiement. | Objet paiement |
payment.refunded |
Le paiement a été remboursé au client. | Objet paiement |
refund.succeeded |
Un remboursement demandé a été exécuté. | Objet remboursement |
refund.failed |
Un remboursement demandé n'a pas pu aboutir. | Objet remboursement |
webhook.test |
Vous avez appelé POST /webhooks/test. |
Message de test |
Tous les événements partent sur le même endpoint : il n'y a pas d'abonnement sélectif. Traitez ceux qui vous concernent, ignorez les autres — et ne renvoyez pas d'erreur sur un événement inconnu, sans quoi vous déclencherez des réessais inutiles.
Corps de la notification
Toutes les notifications ont la même enveloppe :
{
"id": "5c8b8a1e-1f0e-4c0a-9a3e-6f8c2b1d4e77",
"event": "payment.succeeded",
"livemode": true,
"created_at": "2026-08-05T10:25:02+00:00",
"data": {
"reference": "9f2c1a44-8b0e-4b2e-9b0a-2f1d6e5c7a31",
"identifier": "Id2841",
"external_id": "cmd_1042",
"status": "succeeded",
"amount": 5000,
"fee_amount": 90,
"total_amount": 5090,
"currency": "XOF",
"description": "Commande #1042",
"payment_mode": {
"id": 1,
"name": "MTN Bénin",
"type": "mobile_money",
"operator": "mtn",
"country": "Bénin",
"country_code": "BJ"
},
"customer": {
"firstname": "Awa",
"lastname": "Diallo",
"email": "awa@example.com",
"phone_number": "22997000000"
},
"metadata": { "panier": "1042" },
"created_at": "2026-08-05T10:24:11+00:00",
"updated_at": "2026-08-05T10:25:02+00:00"
}
}
data a exactement la même forme que la ressource renvoyée par l'API : un objet
paiement pour les événements payment.*, un objet remboursement pour les
refund.*. Vous pouvez donc réutiliser le même code de désérialisation.
livemode vaut false pour tout ce qui vient du
bac à sable. Testez-le si vous dirigez vos deux environnements
vers le même endpoint — c'est ce qui évite qu'une commande fictive parte en préparation.
En-têtes envoyés
| En-tête | Valeur |
|---|---|
X-MivaaPay-Event | Le nom de l'événement, ex. payment.succeeded. |
X-MivaaPay-Delivery | L'identifiant de la livraison, identique au champ id. Stable à travers les réessais. |
X-MivaaPay-Signature | t=<timestamp>,v1=<hmac_sha256> |
User-Agent | MivaaPay-Webhooks/1.0 |
Content-Type | application/json |
Vérifier la signature
N'importe qui peut appeler votre endpoint. La signature est ce qui distingue une vraie notification d'une requête forgée : vérifiez-la avant de traiter quoi que ce soit.
Le calcul :
- Lisez l'en-tête
X-MivaaPay-Signature, de la formet=1786037102,v1=8f3c…. - Concaténez
<t>, un point, et le corps brut de la requête. - Calculez le HMAC SHA-256 de cette chaîne avec votre
secret, en hexadécimal. - Comparez à
v1en temps constant. - Rejetez si
test vieux de plus de 5 minutes.
La signature porte sur les octets exacts reçus. Si votre framework désérialise le JSON puis le réencode, l'ordre des clés ou l'échappement changeront et la vérification échouera toujours. Récupérez le corps avant tout parsing.
// Laravel — routes/web.php
Route::post('/webhooks/mivaapay', function (Request $request) {
$raw = $request->getContent(); // corps brut, avant tout parsing
$header = $request->header('X-MivaaPay-Signature', '');
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = $parts['t'] ?? '';
$received = $parts['v1'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
return response('horodatage trop ancien', 400);
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, config('services.mivaapay.webhook_secret'));
if (!hash_equals($expected, $received)) {
return response('signature invalide', 400);
}
$event = json_decode($raw, true);
// … traitement idempotent, puis :
return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);
// Express — le corps brut est indispensable
const crypto = require('crypto');
app.post('/webhooks/mivaapay',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-MivaaPay-Signature') || '';
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || age > 300) return res.status(400).send('horodatage trop ancien');
const expected = crypto
.createHmac('sha256', process.env.MIVAAPAY_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest('hex');
const ok = parts.v1
&& parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(400).send('signature invalide');
const event = JSON.parse(req.body.toString());
// … traitement idempotent, puis :
res.status(204).end();
});
# Flask
import hashlib, hmac, os, time
from flask import request, abort
SECRET = os.environ['MIVAAPAY_WEBHOOK_SECRET']
@app.post('/webhooks/mivaapay')
def mivaapay_webhook():
raw = request.get_data() # corps brut, avant tout parsing
header = request.headers.get('X-MivaaPay-Signature', '')
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 abs(time.time() - int(timestamp)) > 300:
abort(400, 'horodatage trop ancien')
expected = hmac.new(
SECRET.encode(),
f'{timestamp}.'.encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
abort(400, 'signature invalide')
event = request.get_json()
# … traitement idempotent, puis :
return '', 204
Répondre correctement
Répondez n'importe quel code 2xx — 200 ou 204 — dès que
vous avez enregistré l'événement. Tout autre code, ou un délai de réponse supérieur à
10 secondes, est traité comme un échec et déclenche un réessai.
Ne faites pas votre traitement métier dans la requête : écrivez l'événement dans une file ou une table, répondez, et traitez ensuite. Un endpoint lent finit par manquer des notifications.
Réessais
Un envoi qui échoue est rejoué jusqu'à 5 tentatives, avec un délai croissant :
| Tentative | Délai après la précédente |
|---|---|
| 1 | immédiate |
| 2 | 10 secondes |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
Au-delà, l'envoi est abandonné et marqué en échec dans le journal. L'information n'est pas perdue
pour autant : l'état du paiement reste lisible par
GET /payments/{reference}. Prévoyez, pour les cas critiques, un rattrapage
périodique qui rejoue les paiements restés pending chez vous.
Rejeux et idempotence
Une même notification peut vous parvenir plusieurs fois : réessai après un timeout où votre serveur avait pourtant traité l'événement, coupure réseau au moment de la réponse. Votre traitement doit donc être idempotent.
Le champ id (identique à l'en-tête X-MivaaPay-Delivery) est stable à
travers les réessais. Stockez-le, et ignorez tout événement déjà vu :
if (WebhookRecu::where('delivery_id', $event['id'])->exists()) {
return response()->noContent(); // déjà traité
}
WebhookRecu::create(['delivery_id' => $event['id'], 'event' => $event['event']]);
// … traitement métier
Raisonnez également en transitions plutôt qu'en événements : si la commande
est déjà marquée payée, un second payment.succeeded ne doit rien déclencher.
Tester votre intégration
POST /webhooks/test envoie un événement webhook.test sur votre endpoint,
signé exactement comme un vrai. C'est le moyen de valider votre vérification de signature sans
déplacer d'argent.
curl -X POST https://api.mivaapay.com/api/v1/webhooks/test \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"
{
"success": true,
"data": {
"sent": true,
"delivery_id": "1f0c9d3a-4f11-9c22-7d5a-1e0b3c68b71e",
"url": "https://boutique.example.com/webhooks/mivaapay"
}
}
Si aucun endpoint n'est configuré, la réponse reste un 200 mais porte
"sent": false et le motif.
Journal des envois
GET /webhooks/deliveries liste les dernières notifications avec leur issue :
code HTTP reçu, nombre de tentatives, date de livraison. C'est le premier endroit où regarder
quand une notification « n'est jamais arrivée ».
{
"success": true,
"data": [
{
"id": "5c8b8a1e-1f0e-4c0a-9a3e-6f8c2b1d4e77",
"event": "payment.succeeded",
"url": "https://boutique.example.com/webhooks/mivaapay",
"status": "success",
"attempts": 2,
"response_code": 204,
"created_at": "2026-08-05T10:25:02+00:00",
"delivered_at": "2026-08-05T10:25:14+00:00"
}
],
"meta": { "current_page": 1, "per_page": 25, "total": 41, "last_page": 2 }
}
Mise en production
- Endpoint en HTTPS, avec un certificat valide.
- Signature vérifiée sur le corps brut, avec une comparaison en temps constant.
- Horodatage rejeté au-delà de 5 minutes.
- Réponse 2xx en moins de 10 secondes, traitement métier en asynchrone.
- Garde d'idempotence sur
id. - Pas d'exception CSRF oubliée : l'endpoint ne doit pas être protégé par un jeton de formulaire.
- Rattrapage périodique pour les paiements restés en attente chez vous.