Webhooks expliqués : charges utiles signées et retries fiables

Backend2026-10-23TryQuickToolBox

Vous déployez une intégration de paiement, le fournisseur envoie un webhook et votre endpoint renvoie 200 — mais trois heures plus tard, le support signale des commandes manquantes. Pire encore : le même événement arrive quatre fois et le client est débité deux fois. Les webhooks semblent simples (un POST HTTP vers votre URL) mais les points délicats sont la confiance et les garanties de livraison.

Ce guide couvre les deux : comment vérifier qu'une charge utile provient bien de l'expéditeur grâce aux charges utiles signées, et comment construire un modèle de retry qui survit aux pannes, aux timeouts et aux livraisons en double.

Ce qu'est réellement un webhook

Un webhook est un appel API inversé. Au lieu que votre serveur interroge GET /events toutes les minutes, le fournisseur pousse une requête HTTP vers une URL que vous avez enregistrée lorsqu'un événement se produit. La charge utile est généralement du JSON, la méthode est presque toujours POST, et la réponse que vous renvoyez est un signal : 2xx signifie « reçu », tout le reste signifie « réessayez plus tard ».

Cette dernière phrase constitue tout le contrat. Tout le reste — signatures, retries, ordonnancement — existe pour rendre ce contrat sûr sur un réseau peu fiable.

Pourquoi les webhooks non signés sont dangereux

Votre endpoint de webhook est une URL publique. Quiconque la découvre peut envoyer un POST avec une fausse charge utile prétendant qu'une commande a été payée. Si vous faites confiance au corps sans vérification, vous avez ouvert une porte vers votre logique de facturation.

Les listes d'IP autorisées aident un peu mais échouent derrière les CDN, les sorties serverless et les changements d'IP des fournisseurs. La réponse robuste est un secret partagé plus une signature HMAC sur le corps brut de la requête.

Comment fonctionnent les charges utiles signées

Le fournisseur et vous partagez une clé secrète. À chaque livraison, le fournisseur calcule un HMAC (généralement SHA-256) sur le corps brut — parfois sur un horodatage plus le corps — et envoie le digest hexadécimal dans un en-tête tel que X-Signature. Vous recalculez le même digest avec votre copie du secret et comparez.

Deux détails comptent plus qu'on ne le pense :

Un exemple minimal de vérification (Node.js)

const crypto = require('crypto');

function verifySignature(rawBody, headerSig, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // Buffer ou chaîne brut, pas du JSON analysé
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headerSig || '', 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Le même schéma fonctionne en Python avec hmac.compare_digest, en Go avec hmac.Equal, et en Java avec MessageDigest.isEqual. Le principe est identique partout : HMAC sur les octets bruts, comparaison à temps constant.

Protection contre le rejeu avec horodatages

Une signature valide prouve que la charge utile provient de quelqu'un possédant le secret — mais une requête capturée peut être rejouée indéfiniment. Les fournisseurs soucieux de cela incluent un horodatage dans la chaîne signée, par exemple t=1712345678,v1=<hex>. De votre côté :

  1. Extrayez l'horodatage de l'en-tête.
  2. Rejetez la requête si elle est plus ancienne qu'une fenêtre de tolérance (souvent 5 minutes).
  3. Recalculez le HMAC sur timestamp + "." + rawBody.

Cela limite les dégâts d'une requête divulguée à quelques minutes au lieu de l'infini.

Concevoir des retries qui livrent réellement

Les réseaux échouent, votre service redémarre, les déploiements font tourner les pods. Un fournisseur sérieux réessaie les livraisons échouées selon un calendrier. Un récepteur sérieux doit supposer que chaque événement peut arriver plus d'une fois.

Règles de retry côté expéditeur

Règles côté récepteur

  1. Vérifiez d'abord, analysez ensuite. Rejetez les signatures invalides avec un 401 avant de toucher à votre base de données.
  2. Persistez l'événement, puis renvoyez 200. Écrivez la charge utile brute et l'ID d'événement dans une file d'attente ou une table, répondez immédiatement et traitez dans un worker. Si le traitement échoue, réessayez en interne.
  3. Rendez les gestionnaires idempotents. Utilisez l'ID d'événement du fournisseur comme clé unique. S'il existe déjà, renvoyez 200 sans réexécuter les effets de bord.
  4. Retournez les erreurs honnêtement. Si votre base de données est en panne, renvoyez 500 pour que le fournisseur réessaie — ne renvoyez pas 200 et ne perdez pas l'événement silencieusement.
RéponseSignification pour l'expéditeurCe qui se passe ensuite
200 / 204LivréPas de retry
400Malformé ou rejetéGénéralement pas de retry ; journalisez pour le débogage
401Signature invalidePas de retry ; enquêtez sur le secret
429Limité en débitRetry avec backoff
5xx / timeoutÉchec temporaireRetry avec backoff

L'idempotence en pratique

L'idempotence n'est pas un mot à la mode ici — c'est la seule chose qui sépare un retry d'un double débit. Le schéma :

INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;

Si l'insertion affecte zéro ligne, vous avez déjà vu cet événement ; accusez réception et passez à la suite. Cela fonctionne même en cas de livraisons concurrentes car la contrainte d'unicité les sérialise au niveau de la base de données.

Pour les effets de bord qui doivent se produire exactement une fois (envoi d'e-mail, débit d'une carte), transmettez votre propre clé d'idempotence en aval — la plupart des API de paiement et d'e-mail en acceptent une.

Ordonnancement, débogage et observabilité

Ne supposez pas que les événements arrivent dans l'ordre. Les retries peuvent réordonner les livraisons, et les workers parallèles aussi. Si l'ordre compte, incluez un numéro de séquence ou un horodatage dans la charge utile et réconciliez l'état plutôt que d'appliquer des deltas aveuglément.

Journalisez chaque tentative de livraison avec l'ID d'événement, le résultat de la signature, le code de réponse et le temps de traitement. Quand un client dit « ma commande n'est jamais arrivée », vous voulez répondre en quelques secondes : le webhook a-t-il été reçu, la signature était-elle valide, le traitement a-t-il réussi ? Une ligne de log structurée par événement suffit.

Pour les endpoints qui reçoivent beaucoup de trafic, surveillez les tempêtes de retry après un déploiement. Un moyen rapide de les repérer est d'examiner les motifs de requêtes dans vos logs d'accès — un pic de chemins identiques depuis une IP avec des réponses 5xx est une boucle de retry classique. Des outils comme l'analyseur de logs Nginx rendent ce motif évident sans écrire une requête à la main.

Erreurs courantes à éviter

FAQ

Comment vérifier une signature de webhook si le corps est du JSON ?

Lisez le corps brut de la requête en tant qu'octets avant toute analyse JSON, calculez le HMAC sur ces octets, et comparez avec l'en-tête en utilisant une fonction à temps constant. Un JSON re-sérialisé produira un digest différent.

Combien de fois un webhook doit-il être réessayé ?

Il n'y a pas de nombre universel, mais 5 à 10 tentatives sur plusieurs heures avec un backoff exponentiel et du jitter est courant. L'important est que les récepteurs soient idempotents, afin que les retries soient sûrs même lorsqu'ils sont fréquents.

Quel code de statut mon endpoint de webhook doit-il renvoyer ?

Renvoyez 2xx uniquement après avoir stocké durablement l'événement. Renvoyez 401 pour les signatures invalides, 400 pour les charges utiles malformées, et 5xx ou 429 lorsque vous voulez que l'expéditeur réessaie.

Si vous testez des charges utiles de webhook ou journalisez le JSON que vous recevez, le formateur JSON est un moyen rapide de pretty-printer et valider un événement capturé avant de construire une logique d'analyse autour de lui.