Webhook
Vous avez un seul webhook pour toutes vos entreprises.
PUT /webhook { "url": "https://hooks.plateforme.example/ripa" }GET /webhookPOST /webhook/rotate-secret (Idempotency-Key obligatoire)L’adresse doit être publique, commencer par https://, et utiliser le port 443 ou 8443. Le premier PUT répond 201 avec le webhook et son secret (whsec_...), montré une seule fois. Conservez-le. Un PUT ultérieur change l’adresse et répond 200 sans secret.
{ "object": "partner_webhook", "data": { "id": "pwh_2xJ4kQ9nVbT7mLpR3sYz", "url": "https://hooks.plateforme.example/ripa", "status": "active", "created_at": "2026-11-05T08:00:00Z", "updated_at": "2026-11-05T08:00:00Z", "secret": "whsec_MfKQ9r8d7C0pXk2VYm1h4jB6nGq3sTu5wZaDeFoHiLc=" }, "meta": { "request_id": "req_07" }}status vaut active ou disabled. GET montre aussi failing_since, last_delivered_at et previous_secret_until, jamais le secret.
Ce que nous envoyons
Section intitulée « Ce que nous envoyons »Un POST par lot d’événements, avec les lignes du flux exactement comme GET /events les renvoie :
{ "batch_id": "pwb_2xJ4kQ9nVbT7mLpR3sYz", "events": [ { "id": "pev_5d41402abc4b2a76b9719d911017c592", "type": "invoice.normalised", "position": 18342, "...": "comme dans le flux" } ]}Un lot contient jusqu’à 100 événements par défaut (réglable par partenaire) et il est coupé quand il est plein ou quand son plus ancien événement a attendu un court moment. L’enveloppe avec batch_id est la nôtre : la spécification Standard Webhooks décrit un événement par requête et ne dit rien des lots. En-têtes :
| En-tête | Valeur |
|---|---|
webhook-id |
l’identifiant du lot |
webhook-timestamp |
l’heure Unix, en secondes, de cet envoi |
webhook-signature |
v1,<signature> ; pendant une rotation, deux entrées séparées par une espace : v1,<nouvelle> v1,<ancienne> |
Content-Type |
application/json |
La livraison est au moins une fois et dans l’ordre : un seul lot est en vol à la fois, et le suivant attend que le précédent soit livré. Dédoublonnez sur l’id de chaque événement (vous pouvez aussi dédoublonner sur l’identifiant du lot). Répondez par un 2xx quelconque en moins de 10 secondes. Une redirection (3xx) n’est pas suivie et compte comme un échec. Une réponse 410 désactive aussitôt le webhook.
Vérifier la signature
Section intitulée « Vérifier la signature »La signature est base64( HMAC-SHA256( clé, "<webhook-id>.<webhook-timestamp>.<corps brut>" ) ), où la clé est le secret après whsec_, décodé de base64. Signez les octets exacts que vous avez reçus : ne décodez pas puis ne re-sérialisez pas le corps. Rejetez un horodatage éloigné de plus de 5 minutes de votre horloge. Acceptez l’appel si une entrée v1, correspond à un secret que vous détenez.
Node.js (avec Express, donnez à la route express.raw({ type: 'application/json' }) pour que req.body soit le Buffer brut) :
const crypto = require('crypto');
// secrets : votre secret actuel et, pendant une rotation, le précédent.function verify(rawBody, headers, secrets) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const header = headers['webhook-signature']; if (!id || !timestamp || !header) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]); const received = header.split(' ') .filter((entry) => entry.startsWith('v1,')) .map((entry) => Buffer.from(entry.slice(3), 'base64'));
return secrets.some((secret) => { const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto.createHmac('sha256', key).update(signed).digest(); return received.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(sig, expected)); });}Python :
import base64import hashlibimport hmacimport time
def verify(raw_body: bytes, headers: dict, secrets: list[str]) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") header = headers.get("webhook-signature") if not (msg_id and timestamp and header): return False try: if abs(time.time() - int(timestamp)) > 300: return False except ValueError: return False
signed = f"{msg_id}.{timestamp}.".encode() + raw_body received = [entry[3:] for entry in header.split(" ") if entry.startswith("v1,")] for secret in secrets: key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() if any(hmac.compare_digest(expected, sig) for sig in received): return True return FalseNouvelles tentatives, désactivation et réactivation
Section intitulée « Nouvelles tentatives, désactivation et réactivation »Une tentative qui échoue (pas de réponse, délai dépassé, réponse non 2xx) est renouvelée après 30 secondes, 2 minutes, 10 minutes, 1 heure, puis toutes les 6 heures. Les échecs d’un récepteur n’ont pas de limite en nombre de tentatives ; la limite est le temps. Après 3 jours sans un seul succès, le webhook est désactivé et votre contact technique en est informé par e-mail. Un webhook qui répond 410 est désactivé aussitôt.
Un webhook désactivé n’envoie rien. Pour le réactiver, faites PUT /webhook avec son adresse (la même convient) : il repart de la position courante. Ce qui s’est passé pendant qu’il était désactivé n’est pas envoyé : le flux sert de rattrapage. Lisez GET /events à partir de la dernière position traitée. En règle générale, prenez un webhook pour un « allez lire le flux », et faites votre travail à partir du flux.
Changer le secret
Section intitulée « Changer le secret »POST /webhook/rotate-secret renvoie un nouveau secret, montré dans cette réponse seulement. L’ancien continue de signer, à côté du nouveau, pendant 24 heures (previous_secret_until) : vous pouvez déployer le nouveau sans interruption.
- L’
Idempotency-Keyest obligatoire. Le rejeu de la même clé répond le même webhook sanssecret. Lisez-le comme « la première réponse ne vous est jamais parvenue » : faites une nouvelle rotation, avec une nouvelle clé. - Une rotation dans la fenêtre de 24 heures garde le plus ancien secret valable comme « précédent » et écarte celui d’entre les deux. Si vous aviez lu la réponse de la première rotation et déployé ce secret (appelons-le S1), puis refait une rotation dans les 24 heures, S1 cesse de signer aussitôt : déployez le nouveau (S2) avant de faire une deuxième rotation, ou ne refaites une rotation qu’une fois le premier déploiement terminé. Un partenaire qui n’a jamais vu S1 vérifie toujours avec le secret d’origine (S0) jusqu’à la fin de la fenêtre.