Webhooks expliqués : charges utiles signées et retries fiables
Votre intégration fonctionne en staging : le fournisseur de paiement déclenche un événement, votre endpoint le reçoit, la commande passe à paid. Puis en production, un client paie deux fois, une livraison disparaît silencieusement pendant un déploiement, et quelqu'un rejoue une requête capturée pour marquer une commande comme remboursée. Ces trois échecs ont la même cause racine : le webhook a été traité comme un simple POST HTTP au lieu d'un canal de livraison au moins une fois, critique pour la sécurité.
Ce guide couvre les deux moitiés d'une implémentation correcte de webhook — vérifier que la charge utile est authentique, et faire en sorte que la livraison survive aux retries — avec du code adaptable à n'importe quelle stack.
Ce qu'est vraiment un webhook
Un webhook est une requête HTTP serveur à serveur qu'un système envoie à un autre lorsqu'un événement se produit. Il inverse le modèle habituel de polling : au lieu que votre application demande « du nouveau ? » chaque minute, le producteur pousse l'événement vers une URL que vous avez enregistrée.
Deux propriétés découlent de cette conception, et les deux comptent :
- Votre endpoint est public. N'importe qui sur Internet peut y faire un POST, donc chaque requête doit être authentifiée.
- La livraison est au moins une fois. Les réseaux échouent, les serveurs redémarrent et les timeouts sont ambigus — l'expéditeur ne peut pas savoir si votre handler s'est exécuté. Il réessaie donc, et vous pouvez voir le même événement plus d'une fois.
Tout ce qui suit découle de ces deux faits.
Signer les charges utiles avec HMAC
L'approche standard est un secret partagé plus un HMAC sur le corps brut de la requête. L'expéditeur calcule une signature et la place dans un en-tête ; vous la recalculez et comparez. Si elles correspondent, la charge utile provient de quelqu'un qui connaît le secret et n'a pas été modifiée en transit.
Trois détails séparent une implémentation fonctionnelle d'une implémentation cassée :
- Signez les octets bruts. N'analysez le JSON qu'après vérification. Re-sérialiser le corps modifie les espaces et l'ordre des clés, et la signature ne correspondra jamais.
- Utilisez une comparaison à temps constant. Un
==naïf fuit des informations de timing. Utilisez la comparaison sécurisée intégrée au langage. - Incluez un timestamp dans la chaîne signée. Signer
timestamp + "." + bodypermet de rejeter les requêtes anciennes, ce qui bloque les attaques par rejeu.
Vérifier une signature en Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recompute over the exact bytes received
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Constant-time compare
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Dans Express, cela nécessite express.raw({ type: 'application/json' }) sur la route du webhook pour que le corps arrive sous forme de Buffer. Si un middleware global express.json() s'exécute en premier, le corps brut a disparu et la vérification échouera — un bug très courant.
Vérifier en Python
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retournez toujours 401 en cas d'échec de vérification et journalisez l'ID de la requête — mais ne journalisez jamais le secret ni la signature complète.
Concevoir des retries qui fonctionnent vraiment
Les retries ne sont utiles que si votre endpoint se comporte de manière prévisible. Le contrat est simple : retournez 2xx rapidement une fois que vous avez accepté l'événement de manière durable, et laissez tout le reste être réessayé.
| Votre réponse | Comportement de l'expéditeur | Que faire à la place |
|---|---|---|
200 / 204 | Marquer comme livré | Correct — accusez réception après mise en file |
400 / 401 | Généralement pas de retry | Corrigez la gestion de la signature ou de la charge utile |
429 / 503 | Retry avec backoff | Retournez ceci en cas de surcharge, pas un 500 générique |
500 / timeout | Retry | Déplacez le travail lent vers une file d'attente |
Deux règles maintiennent cet équilibre :
- Accusez réception rapidement. Vérifiez, persistez l'événement dans une file ou une table, répondez
200. Faites le vrai travail de manière asynchrone. Un handler qui appelle trois API avant de répondre va timeout et déclencher des livraisons en double. - Utilisez un backoff exponentiel avec jitter. Si un expéditeur réessaie immédiatement, une brève panne devient un troupeau rugissant. Un calendrier comme 10s, 30s, 2m, 10m, 1h avec un jitter aléatoire répartit la charge.
Rendez les handlers idempotents
Parce que la livraison est au moins une fois, le même ID d'événement peut arriver deux fois — une fois après un timeout où votre handler a en fait réussi. Stockez les IDs d'événements traités et ignorez les doublons :
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.
Dans la mesure du possible, rendez l'opération métier elle-même idempotente — définissez le statut de la commande à paid plutôt que d'incrémenter un compteur, et utilisez l'ID d'événement comme clé d'idempotence lors des appels aux services en aval.
Une checklist avant la mise en production
- Vérifiez le HMAC sur les octets bruts avec une comparaison à temps constant.
- Rejetez les timestamps plus anciens que quelques minutes pour bloquer les rejeux.
- Persistez l'événement, puis retournez
2xxen une ou deux secondes. - Dédupliquez sur l'ID d'événement avant de faire quoi que ce soit.
- Journalisez l'ID d'événement, le type et le résultat — jamais le secret.
- Exposez un moyen de rejouer manuellement les événements depuis votre tableau de bord ou votre outil d'administration.
- Testez avec des signatures délibérément cassées, des livraisons en double et des handlers lents.
Lorsque vous déboguez une intégration défaillante, le corps brut de la requête est ce que vous regarderez le plus. Le coller dans un formateur JSON rend les objets d'événements imbriqués lisibles sans modifier les octets que vous avez déjà capturés.
FAQ
Pourquoi ma vérification de signature échoue-t-elle alors que la charge utile semble identique ?
Presque toujours parce que le corps a été analysé et re-sérialisé avant le hachage. L'ordre des clés JSON, les espaces et l'échappement Unicode changent pendant le parse/stringify, donc les octets ne correspondent plus à ce que l'expéditeur a signé. Capturez le corps brut et hashez-le.
Dois-je retourner 200 avant ou après le traitement de l'événement ?
Après l'avoir stocké durablement, avant de faire le gros du travail. Si vous traitez d'abord, les appels en aval lents provoquent des timeouts, l'expéditeur réessaie et vous obtenez des doublons. Mettez l'événement en file, répondez immédiatement et traitez de manière asynchrone.
HTTPS suffit-il pour sécuriser un endpoint de webhook ?
Non. TLS protège les données en transit mais ne prouve pas qui a envoyé la requête — n'importe qui peut faire un POST vers une URL publique. La signature HMAC avec un secret partagé authentifie l'expéditeur, et un timestamp signé empêche le rejeu des requêtes capturées.
En résumé
Traitez les webhooks comme toute entrée non fiable : vérifiez la signature sur les octets bruts, contrôlez le timestamp, dédupliquez par ID d'événement et n'accusez réception qu'après avoir stocké l'événement en sécurité. Faites cela, et les retries cessent d'être une source de doubles débits pour devenir ce qu'ils étaient censés être — un filet de sécurité.