Aller au contenu

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, ni localhost, ni adresse de réseau privé (400 INVALID_URL à l’ajout, avec fields.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 dans webhook_secret avec GET /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 :

Terminal
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"]}'
RouteRôle
GET /v1/projets/{id}/webhooksLister les points de terminaison.
POST /v1/projets/{id}/webhooksAjouter 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}/testEnvoyer 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.

Terminal
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks/$SUBSCRIPTION_ID/test" \
  -H "Authorization: Bearer $API_KEY"
Corps envoyé au point de terminaison
{
  "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.

200 · application/json
{ "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énementDéclencheur
payment.succeededPaiement confirmé, montant net crédité.
payment.failedPaiement échoué.
payment.expiredPaiement expiré.
refund.requestedRemboursement enregistré.
refund.succeededRemboursement reçu par le client.
refund.failedRemboursement refusé, fonds revenus au solde.
payout.succeededRetrait versé.
payout.failedRetrait refusé, fonds revenus au solde.
dispute.openedLitige ouvert sur un paiement.
dispute.challengedLitige contesté par le marchand.
dispute.acceptedLitige accepté par le marchand.
dispute.wonLitige tranché en faveur du marchand.
dispute.lostLitige 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

Requête
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 :

ChampDescription
idIdentifiant unique de l’événement, identique à chaque renvoi : clé de déduplication.
typeType, parmi le catalogue (plus test.ping).
created_atDate de l’événement.
livemodetrue 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énementsChamps 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_reasonSignification
destination_limitLe portefeuille destinataire a atteint son plafond.
destination_invalidCompte ou numéro destinataire refusé par l’opérateur.
provider_unavailableService 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}.

  1. Lire le corps brut, avant tout analyse JSON.
  2. Refuser un horodatage trop ancien (5 minutes conseillées) : chaque envoi porte un horodatage neuf.
  3. Recalculer la signature et comparer en temps constant.
JavaScript
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 2xx en 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).