Configurer vos webhooks
Plutôt que d'interroger GET /payments/:reference en boucle, Passerelle peut notifier votre serveur dès qu'un paiement quitte l'état PENDING.
Déclarer une URL
- Par transaction : le champ
webhookUrldePOST /payments(voir Créer votre premier paiement) ne s'applique qu'à cette transaction. - Par défaut pour tout votre compte : se configure depuis le tableau de bord.
[Capture d'écran à venir] Écran des paramètres du tableau de bord permettant de définir l'URL de webhook par défaut du compte et de régénérer le secret de signature.
Si ni l'URL par transaction ni celle du compte ne sont renseignées, aucun webhook n'est envoyé — c'est une fonctionnalité entièrement optionnelle.
Payload reçu
{
"reference": "pay_0d9ace5b-...",
"status": "SUCCEEDED",
"amount": 1000,
"currency": "XOF",
"country": "TG",
"paymentMethod": null,
"createdAt": "2026-09-17T10:00:00.000Z",
"updatedAt": "2026-09-17T10:02:30.000Z"
}
status vaut SUCCEEDED, FAILED ou EXPIRED — jamais PENDING (le webhook n'est envoyé qu'une fois la transaction résolue).
paymentMethod vaut null si vous n'avez pas précisé ce champ à la création du paiement (voir Créer votre premier paiement) — cela signifie "tous les moyens de paiement acceptés", pas une erreur. Un webhook authentique et correctement signé peut tout à fait porter paymentMethod: null : ne l'exigez jamais comme non-nul dans votre schéma de validation. country, en revanche, est toujours renseigné (fourni ou déduit du numéro de téléphone à la création — sinon la création du paiement échoue directement).
Livré par une requête POST vers votre URL, jusqu'à 3 tentatives en cas d'échec (délai de 2s puis 10s entre les tentatives). Seule une réponse HTTP 200 exactement de votre serveur compte comme un succès — répondez vite, Passerelle abandonne l'attente au bout de 10 secondes. Le corps de votre réponse n'est jamais lu : renvoyez-le vide, son contenu n'a aucune importance. Au-delà des 3 tentatives, la livraison est abandonnée — rattrapez le statut avec GET /payments/:reference.
Vérifier la signature
Un secret de signature est généré automatiquement à la création de votre compte et consultable depuis le tableau de bord (ou GET /merchants/me, champ webhookSigningSecret). Vous pouvez le régénérer ou désactiver la signature à tout moment — c'est donc une propriété de votre compte, pas un choix à faire à chaque requête : soit vos webhooks sont toujours signés, soit ils ne le sont jamais.
Si un secret est configuré (recommandé)
Chaque requête porte un en-tête X-Passerelle-Signature: sha256=<signature> — un HMAC-SHA256 du corps JSON brut (avant tout parsing) avec votre secret. Vérifiez-la avant de traiter le webhook ; sans ça, n'importe qui connaissant votre URL peut vous faire croire à un paiement.
- Node.js
- Dart
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.PASSERELLE_WEBHOOK_SECRET; // GET /merchants/me → webhookSigningSecret
app.post(
'/webhooks/passerelle',
express.raw({ type: 'application/json' }), // corps brut requis pour le HMAC — pas express.json()
(req, res) => {
const signatureHeader = req.get('X-Passerelle-Signature') ?? '';
const expected =
'sha256=' +
crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex');
const valid =
signatureHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
if (!valid) {
return res.sendStatus(400); // signature absente ou invalide → on ignore le paiement
}
const event = JSON.parse(req.body.toString('utf8'));
console.log(`Paiement ${event.reference} → ${event.status}`);
// ... votre logique métier (mettre à jour la commande, etc.)
res.sendStatus(200); // seul le code compte, le corps de la réponse est ignoré
},
);
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart'; // pub.dev: crypto
final webhookSecret = Platform.environment['PASSERELLE_WEBHOOK_SECRET']!; // GET /merchants/me → webhookSigningSecret
Future<void> main() async {
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
if (request.method != 'POST' || request.uri.path != '/webhooks/passerelle') {
request.response.statusCode = 404;
await request.response.close();
continue;
}
final rawBody = await utf8.decoder.bind(request).join(); // corps brut requis pour le HMAC
final signatureHeader = request.headers.value('x-passerelle-signature') ?? '';
final expected =
'sha256=' +
Hmac(sha256, utf8.encode(webhookSecret)).convert(utf8.encode(rawBody)).toString();
if (!_constantTimeEquals(expected, signatureHeader)) {
request.response.statusCode = 400; // signature absente ou invalide → on ignore le paiement
await request.response.close();
continue;
}
final event = jsonDecode(rawBody) as Map<String, dynamic>;
print('Paiement ${event['reference']} → ${event['status']}');
// ... votre logique métier (mettre à jour la commande, etc.)
request.response.statusCode = 200; // seul le code compte, le corps de la réponse est ignoré
await request.response.close();
}
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return diff == 0;
}
Si aucun secret n'est configuré
L'en-tête X-Passerelle-Signature n'est alors jamais envoyé — vous recevez le payload tel quel, sans moyen de vérifier qu'il vient bien de Passerelle. Utile en local/sandbox le temps de brancher votre intégration, mais déconseillé en production : gardez webhookUrl secrète, ou activez la signature depuis le tableau de bord.
- Node.js
- Dart
const express = require('express');
const app = express();
app.post('/webhooks/passerelle', express.json(), (req, res) => {
const event = req.body; // déjà parsé — aucune vérification de provenance possible
console.log(`Paiement ${event.reference} → ${event.status}`);
// ... votre logique métier (mettre à jour la commande, etc.)
res.sendStatus(200); // seul le code compte, le corps de la réponse est ignoré
});
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
if (request.method != 'POST' || request.uri.path != '/webhooks/passerelle') {
request.response.statusCode = 404;
await request.response.close();
continue;
}
final rawBody = await utf8.decoder.bind(request).join();
final event = jsonDecode(rawBody) as Map<String, dynamic>; // aucune vérification de provenance possible
print('Paiement ${event['reference']} → ${event['status']}');
// ... votre logique métier (mettre à jour la commande, etc.)
request.response.statusCode = 200; // seul le code compte, le corps de la réponse est ignoré
await request.response.close();
}
}