Docs API

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 :

Un endpoint par environnement

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']
200
{
  "success": true,
  "data": {
    "url": "https://boutique.example.com/webhooks/mivaapay",
    "secret": "whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  }
}
Gardez le secret

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énementDéclencheurContenu 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 :

POST vers votre endpoint
{
  "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êteValeur
X-MivaaPay-EventLe nom de l'événement, ex. payment.succeeded.
X-MivaaPay-DeliveryL'identifiant de la livraison, identique au champ id. Stable à travers les réessais.
X-MivaaPay-Signaturet=<timestamp>,v1=<hmac_sha256>
User-AgentMivaaPay-Webhooks/1.0
Content-Typeapplication/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 :

  1. Lisez l'en-tête X-MivaaPay-Signature, de la forme t=1786037102,v1=8f3c….
  2. Concaténez <t>, un point, et le corps brut de la requête.
  3. Calculez le HMAC SHA-256 de cette chaîne avec votre secret, en hexadécimal.
  4. Comparez à v1 en temps constant.
  5. Rejetez si t est vieux de plus de 5 minutes.
Le corps brut, pas le JSON réencodé

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 2xx200 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 :

TentativeDélai après la précédente
1immédiate
210 secondes
31 minute
45 minutes
530 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 :

Garde d'idempotence
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.

Envoyer un test
curl -X POST https://api.mivaapay.com/api/v1/webhooks/test \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"
200
{
  "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 ».

200 — extrait
{
  "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.