REST API design - 21 - Webhooks : quand c'est ton API qui appelle

HTTP callbacks, retry stratégies, signatures HMAC-SHA256, idempotence, dead letter queues et l'exemple Stripe.

REST API design 22 / 25
  1. 01 REST API design - 00 - Pourquoi le design de ton API change tout
  2. 02 REST API design - 01 - Les principes REST que personne ne lit
  3. 03 REST API design - 02 - Des URLs qui ont du sens
  4. 04 REST API design - 03 - Les méthodes HTTP, pour de vrai
  5. 05 REST API design - 04 - Les codes de statut HTTP qu'il faut connaître
  6. 06 REST API design - 05 - Body, headers et le diable dans les détails
  7. 07 REST API design - 06 - La pagination, ou comment ne pas tuer ta base
  8. 08 REST API design - 07 - Filtrage et tri sans prise de tête
  9. 09 REST API design - 08 - La validation avec Zod, gardien de ton API
  10. 10 REST API design - 09 - Erreurs : un format que tes clients vont adorer
  11. 11 REST API design - 10 - Authentification : JWT, API keys et OAuth2
  12. 12 REST API design - 11 - Versioning : quand et comment faire évoluer ton API
  13. 13 REST API design - 12 - CORS : comprendre et debugger les erreurs cross-origin
  14. 14 REST API design - 13 - Rate limiting : protéger ton API sans frustrer tes clients
  15. 15 REST API design - 14 - Caching : les bonnes réponses sont celles qu'on n'envoie pas
  16. 16 REST API design - 15 - Upload de fichiers : multipart, signed URLs et streaming
  17. 17 REST API design - 16 - Relations entre ressources : embarquer, lier ou les deux
  18. 18 REST API design - 17 - HATEOAS : des liens dans tes réponses
  19. 19 REST API design - 18 - OpenAPI : le contrat entre ton API et le monde
  20. 20 REST API design - 19 - Documentation : une API non documentee est une API inutile
  21. 21 REST API design - 20 - Testing : tester ton API sans devenir fou
  22. 22 REST API design - 21 - Webhooks : quand c'est ton API qui appelle
  23. 23 REST API design - 22 - Performance : quand chaque milliseconde compte
  24. 24 REST API design - 23 - Sécurité : les attaques que tu vas subir (et comment t'en protéger)
  25. 25 REST API design - 24 - Glossaire : tous les termes REST API en un seul endroit

21 - Webhooks : quand c'est ton API qui appelle

Ce que tu vas apprendre

  • Ce qu'est un webhook et quand l'utiliser
  • Les stratégies de retry et le backoff exponentiel
  • La vérification des signatures HMAC-SHA256
  • L'idempotence pour gerer les doublons
  • Les dead letter queues pour les webhooks en échec

Prerequisites

Avoir lu l'article sur le testing. Comprendre les requêtes HTTP et savoir ce qu'est un endpoint POST.


Le polling, c'est comme un enfant a l'arriere de la voiture qui demande "on est arrives ?" toutes les 30 secondes. Le webhook, c'est quand tu lui dis "je te previens quand on arrive". Un webhook inverse le flux : au lieu que ton client interroge ton API en boucle, c'est ton API qui notifie le client quand quelque chose se passe.

Le principe

  1. Le client s'inscrit en fournissant une URL de callback
  2. Un événement se produit dans ton système (commande payee, user créé, etc.)
  3. Ton API envoie un POST a l'URL du client avec les détails de l'événement
POST https://client.example.com/webhooks HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...

{
  "event": "order.paid",
  "timestamp": "2026-03-29T14:30:00Z",
  "data": {
    "order_id": 42,
    "amount": 3500,
    "currency": "EUR"
  }
}

Le client répond 200 OK pour confirmer la reception. Tout autre code (ou un timeout) déclenché un retry.

L'inscription

Tu as besoin d'un endpoint pour enregistrer les webhooks :

typescriptapp.post("/webhooks/subscriptions", async (req, res) => {
  const { url, events, secret } = req.body;

  // Verifie que l'URL est joignable
  try {
    const ping = await fetch(url, { method: "POST", body: JSON.stringify({ event: "ping" }) });
    if (!ping.ok) throw new Error("URL non joignable");
  } catch {
    return res.status(422).json({ detail: "L'URL de webhook n'est pas joignable." });
  }

  const subscription = await db.insert(webhookSubscriptions).values({
    url,
    events, // ["order.paid", "order.shipped"]
    secret: crypto.randomBytes(32).toString("hex")
  });

  res.status(201).json({
    id: subscription.id,
    secret: subscription.secret // Le client stocke ce secret pour verifier les signatures
  });
});

Signatures HMAC-SHA256

Sans vérification de signature, n'importe qui peut envoyer un faux webhook a l'URL du client. La solution : signer le body avec un secret partage.

Cote API (envoi) :

typescriptimport crypto from "crypto";

function signPayload(payload: string, secret: string): string {
  return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}

async function sendWebhook(url: string, event: object, secret: string) {
  const body = JSON.stringify(event);
  const signature = signPayload(body, secret);

  await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Webhook-Signature": `sha256=${signature}`,
      "X-Webhook-Timestamp": String(Date.now())
    },
    body
  });
}

Cote client (reception) :

typescriptapp.post("/webhooks", (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  const expected = `sha256=${signPayload(JSON.stringify(req.body), WEBHOOK_SECRET)}`;

  if (signature !== expected) {
    return res.status(401).json({ detail: "Signature invalide" });
  }

  // Traiter l'evenement
  res.status(200).end();
});

Stripe fait exactement ca. Leur librairie stripe.webhooks.constructEvent() vérifié la signature et rejette les payloads falsifies. Sur paltemps.fr, chaque webhook entrant passe par cette vérification.

Retry avec backoff exponentiel

Le serveur du client peut etre temporairement indisponible. Tu ne peux pas abandonner au premier échec. La stratégie standard :

Tentative 1 : immediate
Tentative 2 : apres 1 minute
Tentative 3 : apres 5 minutes
Tentative 4 : apres 30 minutes
Tentative 5 : apres 2 heures
Tentative 6 : apres 8 heures
typescriptconst RETRY_DELAYS = [0, 60, 300, 1800, 7200, 28800]; // en secondes

async function deliverWebhook(subscription: Subscription, event: object, attempt = 0) {
  try {
    const res = await fetch(subscription.url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10000) // timeout 10s
    });

    if (res.status >= 200 && res.status < 300) {
      await markDelivered(event.id);
      return;
    }

    throw new Error(`HTTP ${res.status}`);
  } catch (error) {
    if (attempt < RETRY_DELAYS.length - 1) {
      await scheduleRetry(subscription, event, attempt + 1, RETRY_DELAYS[attempt + 1]);
    } else {
      await moveToDeadLetter(subscription, event, error);
    }
  }
}

Idempotence

Avec les retries, le client peut recevoir le meme événement plusieurs fois. Tu dois rendre le traitement idempotent. La solution : un ID unique par événement.

json{
  "id": "evt_a1b2c3d4",
  "event": "order.paid",
  "data": { "order_id": 42 }
}

Cote client :

typescriptapp.post("/webhooks", async (req, res) => {
  const eventId = req.body.id;

  // Deja traite ?
  const exists = await db.select().from(processedEvents).where(eq(processedEvents.id, eventId));
  if (exists.length > 0) {
    return res.status(200).end(); // Deja traite, on repond OK
  }

  // Traiter l'evenement
  await processEvent(req.body);
  await db.insert(processedEvents).values({ id: eventId, processed_at: new Date() });

  res.status(200).end();
});

Dead letter queues

Apres toutes les tentatives, si le webhook n'est toujours pas delivre, il atterrit dans une dead letter queue. C'est un stockage tampon ou les événements echoues attendent une intervention manuelle.

typescriptasync function moveToDeadLetter(subscription: Subscription, event: object, error: Error) {
  await db.insert(deadLetterQueue).values({
    subscription_id: subscription.id,
    event: JSON.stringify(event),
    error: error.message,
    failed_at: new Date()
  });

  // Notifier l'admin
  await notifyAdmin(`Webhook en echec apres 6 tentatives pour ${subscription.url}`);
}

Propose aussi un endpoint pour que le client puisse relister et relancer ses webhooks echoues :

GET /webhooks/subscriptions/42/failed-deliveries
POST /webhooks/subscriptions/42/failed-deliveries/replay

L'exemple Stripe

Stripe est la référencé en matière de webhooks. Voici ce qu'ils font bien :

  • Chaque événement a un ID unique et un type precis (invoice.paid, customer.created)
  • Les signatures utilisent HMAC-SHA256 avec un timestamp pour éviter les replay attacks
  • Les retries suivent un backoff exponentiel sur 3 jours
  • Le dashboard montre les deliveries echouees avec les logs
  • Un endpoint permet de retester un événement manuellement

Si tu concois des webhooks, Stripe est ton modèle.

Résumé

  • Les webhooks inversent le flux : ton API notifie le client au lieu qu'il te poll
  • Signe chaque payload avec HMAC-SHA256 et un secret partage
  • Implemente un retry avec backoff exponentiel (6 tentatives sur plusieurs heures)
  • Rends le traitement idempotent avec un ID unique par événement
  • Les dead letter queues capturent les webhooks qui echouent apres toutes les tentatives
  • Inspire-toi de Stripe pour le design de tes webhooks

Article précédent : Testing Article suivant : Performance

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.