Aller au contenu

Webhook

Vous avez un seul webhook pour toutes vos entreprises.

PUT /webhook { "url": "https://hooks.plateforme.example/ripa" }
GET /webhook
POST /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.

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.

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 base64
import hashlib
import hmac
import 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 False

Nouvelles 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.

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-Key est obligatoire. Le rejeu de la même clé répond le même webhook sans secret. 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.