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
- Le client s'inscrit en fournissant une URL de callback
- Un événement se produit dans ton système (commande payee, user créé, etc.)
- 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