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

Backend2026-11-05TryQuickToolBox

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 :

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 :

  1. 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.
  2. Utilisez une comparaison à temps constant. Un == naïf fuit des informations de timing. Utilisez la comparaison sécurisée intégrée au langage.
  3. Incluez un timestamp dans la chaîne signée. Signer timestamp + "." + body permet 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éponseComportement de l'expéditeurQue faire à la place
200 / 204Marquer comme livréCorrect — accusez réception après mise en file
400 / 401Généralement pas de retryCorrigez la gestion de la signature ou de la charge utile
429 / 503Retry avec backoffRetournez ceci en cas de surcharge, pas un 500 générique
500 / timeoutRetryDéplacez le travail lent vers une file d'attente

Deux règles maintiennent cet équilibre :

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

  1. Vérifiez le HMAC sur les octets bruts avec une comparaison à temps constant.
  2. Rejetez les timestamps plus anciens que quelques minutes pour bloquer les rejeux.
  3. Persistez l'événement, puis retournez 2xx en une ou deux secondes.
  4. Dédupliquez sur l'ID d'événement avant de faire quoi que ce soit.
  5. Journalisez l'ID d'événement, le type et le résultat — jamais le secret.
  6. Exposez un moyen de rejouer manuellement les événements depuis votre tableau de bord ou votre outil d'administration.
  7. 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é.