Tous les providers webhook majeurs — Stripe, GitHub, Shopify, Twilio, Slack — livrent le même événement plus d'une fois, intentionnellement. C'est l'at-least-once delivery. Si votre handler n'est pas conçu pour, vous aurez des doubles facturations, des labels GitHub dupliqués, des notifications Slack envoyées deux fois.
Pourquoi les doublons
Le provider retente quand il ne reçoit pas un 2xx à temps. Fenêtres typiques : Stripe 3 jours, GitHub 8 tentatives sur 8h, Shopify 48 heures. La plupart du temps la livraison originale a réussi — le réseau a juste perdu la réponse. Le provider ne le sait pas. Il retente. Votre handler tourne deux fois.
Ce n'est pas un bug à corriger. C'est une propriété du système. Concevez avec.
Le pattern d'idempotence qui marche
Chaque événement majeur a un ID unique. Stripe : evt_xxx. GitHub : X-GitHub-Delivery. Shopify : X-Shopify-Webhook-Id. Stockez l'ID au premier traitement. Au retry, vérifiez — si déjà vu, 200 sans relancer le handler.
async function handleWebhook(event) {
const eventId = event.id;
const inserted = await db.processedEvents.insertIfNew({
eventId,
receivedAt: new Date(),
});
if (!inserted) {
return { status: 200, body: 'already processed' };
}
await actuallyHandleEvent(event);
return { status: 200 };
}
Détail crucial : l'insert et la logique doivent être atomiques ensemble, ou l'insert d'abord. Sinon un crash en plein handler laisse aucune trace et le retry relance.
Où stocker la clé de dédup
- Même transaction DB que l'écriture métier. Le mieux. Contrainte unique sur l'event ID.
- Redis avec TTL long. OK pour événements à faible enjeu. TTL = fenêtre de retry la plus longue, 7 jours minimum.
- Cache mémoire. Non. Le process redémarre, le cache disparaît, le retry réussit à relancer un handler déjà exécuté.
À quoi ressemble "non idempotent" en vrai
Cas réel : un handler Stripe invoice.payment_succeeded qui appelait account.credit(amount). Chaque paiement ajoutait du solde. La plupart du temps une fois. Occasionnellement deux quand un retry attrapait le même événement. Trouvé parce qu'un client a poliment demandé pourquoi sa facture de 50€ avait crédité son compte de 100€.
La correction : un index unique sur events_processed.stripe_event_id et un garde en haut du handler. Onze lignes.
Le timing du handler compte aussi
Timeouts durs : GitHub 10s, Discord interactions 3s, Stripe et Shopify plus permissifs (~30s) mais retries quand même si lent. Répondez vite, travaillez en async.
async function handleWebhook(event) {
if (await alreadyProcessed(event.id)) {
return { status: 200 };
}
await queue.enqueue('process-stripe-event', event);
return { status: 200 };
}
Le worker fait le gros du travail. Le handler webhook est un dédoubleur + un producteur de queue.
Testez-le en local
Tester l'idempotence est exactement ce pour quoi le rejeu tunnel a été fait. Capturez une livraison dans l'historique du tunnel et rejouez-la dix fois. Le premier appel écrit en base. Les neuf suivants retournent 200 sans toucher la base.
Politiques de retry par provider
- Stripe : 3 jours, backoff exponentiel, 16 tentatives max.
- GitHub : 8 tentatives sur ~8h.
- Shopify : 48h, backoff. Désactive après 19 échecs consécutifs sur 48h.
- Twilio : 1 retry par défaut, jusqu'à 3 configurable.
- Slack : 3 retries (1s, 30s, 5min).
Le bilan
La livraison dupliquée est un contrat, pas un défaut. Si vous avez livré un handler qui appelle increment, send_email ou charge_card sans clé de dédup, vous avez livré un bug. Corriger est petit. Découvrir après une plainte client est cher.
Voir aussi guide de rejeu webhook et guide vérification de signature. Rejoignez la waitlist PortPreview.