Webhooks
Recevoir les événements 221 Pay, vérifier la signature X-221-Signature et traiter les renvois sans doublon.
221 Pay notifie le serveur du marchand à chaque changement d’issue d’un paiement, d’un remboursement, d’un retrait ou d’un litige. Le webhook est un signal : il déclenche la relecture de l’objet, qui fait foi.
Ajouter un point de terminaison
Tableau de bord, Développeurs > Webhooks (administrateur du projet) : saisir l’URL et cocher les événements.
- URL
https://joignable depuis Internet : en production, nilocalhost, ni adresse de réseau privé (400 INVALID_URLà l’ajout, avecfields.url). - 5 points de terminaison au plus par projet, une URL par point.
- Un seul secret de signature par projet (
whsec_…), commun à tous ses points de terminaison. Les administrateurs le voient dans Développeurs > Webhooks, carte « Signature des envois » : secret masqué, boutons « Afficher » et « Copier ». L’API le renvoie danswebhook_secretavecGET /v1/projets/{id}(session d’un administrateur).$PROJECT_ID: voir Authentification, section « Identifiant du projet ».
Sur un serveur de développement (hors production), une adresse locale ou privée
(http://localhost:3000/…, par exemple) est acceptée, et seulement là. Le point
de terminaison porte alors local_only: true, signe d’une adresse que la
production refuserait (http://, localhost, réseau privé) : ses événements ne
sont livrés que par le serveur de développement. local_only figure sur chaque
point de terminaison renvoyé par GET /v1/projets/{id}/webhooks.
Par l’API, avec la clé du projet (toute portée, rôle administrateur) ou la session d’un administrateur :
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://boutique.example/webhooks/221", "events": ["payment.succeeded", "payment.failed", "refund.succeeded"]}'| Route | Rôle |
|---|---|
GET /v1/projets/{id}/webhooks | Lister les points de terminaison. |
POST /v1/projets/{id}/webhooks | Ajouter un point : url et events, choisis dans le catalogue. |
PATCH /v1/projets/{id}/webhooks/{subscriptionId} | Remplacer events. |
DELETE /v1/projets/{id}/webhooks/{subscriptionId} | Retirer le point (204). |
POST /v1/projets/{id}/webhooks/{subscriptionId}/test | Envoyer un événement de test. |
Envoyer un événement de test
Pour vérifier l’URL et la signature sans attendre un vrai paiement,
POST /v1/projets/{id}/webhooks/{subscriptionId}/test envoie un événement
test.ping signé au point de terminaison, comme les autres événements (même
secret, mêmes X-221-Timestamp et X-221-Signature). Il porte l’en-tête
X-221-Event: test.ping à la place de X-221-Event-Id : dédupliquer sur le
champ id du corps. Les droits sont ceux de PATCH : la session d’un
administrateur, ou une clé du projet. Une clé n’atteint que les points dont les
modes et les événements restent dans les siens ; sinon 404.
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks/$SUBSCRIPTION_ID/test" \
-H "Authorization: Bearer $API_KEY"{
"id": "evt_…",
"type": "test.ping",
"created_at": "2026-10-07T10:00:00Z",
"livemode": false,
"data": {}
}livemode vaut true seulement pour un point qui ne reçoit que le mode live.
test.ping n’appartient pas au catalogue : il ne s’active pas et n’est jamais
renvoyé automatiquement.
{ "event": "test.ping", "livemode": false, "delivered": true, "status": 200 }delivered vaut true quand le récepteur répond 2xx. status est le code
HTTP du récepteur, null s’il n’a pas répondu ; error donne alors la cause
technique. Limite : 3 envois par minute et par point de terminaison, puis
429 RATE_LIMITED avec Retry-After, en secondes.
Catalogue des événements
| Événement | Déclencheur |
|---|---|
payment.succeeded | Paiement confirmé, montant net crédité. |
payment.failed | Paiement échoué. |
payment.expired | Paiement expiré. |
refund.requested | Remboursement enregistré. |
refund.succeeded | Remboursement reçu par le client. |
refund.failed | Remboursement refusé, fonds revenus au solde. |
payout.succeeded | Retrait versé. |
payout.failed | Retrait refusé, fonds revenus au solde. |
dispute.opened | Litige ouvert sur un paiement. |
dispute.challenged | Litige contesté par le marchand. |
dispute.accepted | Litige accepté par le marchand. |
dispute.won | Litige tranché en faveur du marchand. |
dispute.lost | Litige tranché en faveur du client. |
Le catalogue est aussi servi par GET /v1/webhook-event-types, sans clé.
dataset.updated y figure : il appartient à l’API de données, avec un corps
différent.
Requête reçue
POST /webhooks/221 HTTP/1.1
Content-Type: application/json
X-221-Event-Id: evt_6a1f0b52-8e3c-4d7a-9f21-0c4e5b7d8a13
X-221-Timestamp: 1791367200
X-221-Signature: v1=5d41402abc4b2a76b9719d911017c592…
{"id":"evt_6a1f0b52-8e3c-4d7a-9f21-0c4e5b7d8a13","type":"payment.succeeded","created_at":"2026-10-07T10:00:03Z","object_type":"payment","object_id":"8d1f0c6e-3b7a-4c55-9a51-2f0e5c1d7a90","livemode":false}Tous les événements ont les quatre mêmes champs :
| Champ | Description |
|---|---|
id | Identifiant unique de l’événement, identique à chaque renvoi : clé de déduplication. |
type | Type, parmi le catalogue (plus test.ping). |
created_at | Date de l’événement. |
livemode | true pour un événement du mode live, false en mode test. |
Le reste dépend du type. Aiguiller le traitement sur type, jamais sur la
présence d’un champ.
| Événements | Champs en plus |
|---|---|
Paiements (payment.*, refund.*, payout.*, dispute.*) | object_type (payment, refund, payout ou dispute) ; object_id, l’identifiant de l’objet, à relire avec GET /v1/payments/{id}, /v1/refunds/{id}, /v1/payouts/{id} ou /v1/disputes/{id} ; failure_reason sur payout.failed seulement : destination_limit, destination_invalid ou provider_unavailable (absent sur les anciens retraits). |
test.ping et événements de l’API de données (dataset.updated) | data, un objet propre à l’événement (vide pour test.ping). |
Dans payout.failed, failure_reason explique l’échec :
failure_reason | Signification |
|---|---|
destination_limit | Le portefeuille destinataire a atteint son plafond. |
destination_invalid | Compte ou numéro destinataire refusé par l’opérateur. |
provider_unavailable | Service de paiement momentanément indisponible. |
Les fonds sont revenus au solde dans les trois cas. Voir Retraits.
Vérifier la signature
X-221-Signature vaut v1= suivi du HMAC-SHA256 hexadécimal, calculé avec
le secret du projet sur la chaîne {X-221-Timestamp}.{corps brut}.
- Lire le corps brut, avant tout analyse JSON.
- Refuser un horodatage trop ancien (5 minutes conseillées) : chaque envoi porte un horodatage neuf.
- Recalculer la signature et comparer en temps constant.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify221(rawBody, timestamp, signature, secret, toleranceSeconds = 300) {
if (!timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= toleranceSeconds)) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Express : le corps doit rester brut.
app.post('/webhooks/221', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify221(raw, req.get('X-221-Timestamp'), req.get('X-221-Signature'), process.env.WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
res.sendStatus(200);
// Ensuite : ignorer event.id déjà traité, puis relire l’objet event.object_id.
});Répondre et traiter
- Répondre
2xxen moins de 5 secondes ; traiter ensuite, hors de la requête. - Une redirection (
3xx) n’est pas suivie et compte comme un échec. - Dédupliquer sur
id: un même événement peut arriver plusieurs fois. - Relire l’objet avant d’agir : l’ordre d’arrivée des événements n’est pas garanti.
Renvois
Sans réponse 2xx, l’événement est renvoyé 30 secondes, 2 minutes,
10 minutes, 1 heure puis 24 heures après la tentative précédente, avec un
léger décalage aléatoire : 6 tentatives au total. Un événement n’est marqué
livré qu’une fois tous les points de terminaison abonnés en 2xx ; sinon il
est renvoyé à tous, d’où la déduplication.
Tableau de bord, Développeurs > Webhooks : historique des événements, détail de chaque tentative (statut, corps de réponse) et renvoi manuel (rôle utilisateur ou administrateur).