Stripe livre ses webhooks « au moins une fois » : les doublons et les rejeux font partie du contrat, pas d'un bug. Une intégration de production vérifie la signature sur le corps brut de la requête, déduplique sur l'identifiant d'événement avec une contrainte d'unicité persistante, répond 2xx immédiatement et fait le vrai travail en asynchrone. Pour l'état, elle se fie à l'API Stripe plutôt qu'à la charge utile de l'événement, et un job de réconciliation couvre ce que les webhooks ratent.
Pourquoi Stripe envoie-t-il le même événement deux fois ?
Stripe garantit une livraison « au moins une fois » : si votre endpoint expire, répond autre chose qu'un 2xx, ou si la connexion tombe après que vous avez traité l'événement, Stripe renvoie le même événement. En mode live, il réessaie avec un backoff exponentiel pendant trois jours au maximum. Les doublons sont donc un comportement attendu, que votre handler doit traiter comme une entrée ordinaire.
La logique de retry ne peut pas distinguer « votre handler a échoué » de « votre handler a réussi mais la réponse s'est perdue ». Si le traitement s'est terminé et que la connexion est tombée avant que le 200 ne quitte votre load balancer, Stripe renvoie l'événement. C'est le comportement correct de leur côté, conformément à la documentation des webhooks : la seule hypothèse de conception sûre est que chaque événement peut arriver plus d'une fois.
Comment vérifier la signature sans casser le corps brut ?
La vérification s'appuie sur l'en-tête Stripe-Signature et votre secret d'endpoint via stripe.webhooks.constructEvent, qui exige le corps brut exact de la requête. Tout body parser exécuté avant (express.json, le traitement JSON par défaut d'un framework) réécrit les octets et casse le contrôle. Montez express.raw sur la seule route du webhook et laissez les autres routes sur le parser habituel.
constructEvent recalcule un HMAC sur les octets reçus et le compare à l'en-tête Stripe-Signature, avec une tolérance par défaut de cinq minutes sur l'horodatage embarqué, ce qui limite le rejeu de charges utiles capturées. Sur Fastify, enregistrez un content type parser en corps brut pour cette route : le principe est identique. Pour les tests en local, la CLI Stripe relaie les événements vers localhost et peut en déclencher ou en renvoyer à la demande.
Comment dédupliquer les événements par event.id ?
Chaque événement Stripe porte un identifiant unique (evt_...). Stockez-le dans une table persistante avec une contrainte d'unicité et insérez avant de traiter : si l'insertion viole la contrainte, l'événement a déjà été vu, accusez réception et arrêtez. Cette approche bat le lire-puis-écrire, qui laisse deux livraisons concurrentes passer le test d'existence et traiter l'événement deux fois.
| Stratégie de déduplication | Sûre en concurrence | Survit aux redémarrages | Verdict |
|---|---|---|---|
| Ensemble en mémoire | Non | Non | Échoue au redéploiement et en multi-instances ; à éviter |
| Lire-puis-écrire (SELECT, puis INSERT) | Non | Oui | Deux livraisons concurrentes passent toutes deux la lecture |
| INSERT avec contrainte d'unicité | Oui | Oui | La base arbitre ; interceptez la violation |
| Redis SET NX avec TTL | Oui | Selon la configuration de persistance | Raisonnable sans base relationnelle sous la main |
Combien de temps conserver les event ids traités ?
Purgez les lignes après environ 30 jours : les retries en mode live s'arrêtent au bout de trois jours au maximum, et l'API Events conserve les événements 30 jours (à la date où nous écrivons), ce qui couvre aussi les renvois manuels depuis le dashboard. Attention : la déduplication sur event.id n'a rien à voir avec les clés d'idempotence que vous envoyez sur vos appels sortants à l'API Stripe (requêtes idempotentes) ; un système de production a besoin des deux.
Voici la forme d'un handler qui fait tout cela correctement :
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const app = express()
// Corps brut sur cette route uniquement : express.json() casserait la vérification de signature
app.post('/stripe/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).send(`Signature verification failed: ${err.message}`)
}
// Insertion idempotente : la contrainte d'unicité sur event_id sert de barrière anti-doublon
try {
await db.query(
'INSERT INTO stripe_events (event_id, type) VALUES ($1, $2)',
[event.id, event.type]
)
} catch (err) {
if (err.code === '23505') return res.sendStatus(200) // doublon : déjà enregistré
return res.sendStatus(500) // stockage en échec : laisser Stripe réessayer
}
// Accuser réception maintenant, traiter plus tard : déléguer à une file ou un job runner
await enqueue({ eventId: event.id, type: event.type })
return res.sendStatus(200)
})
Un doute sur la tenue de votre intégration Stripe face aux doublons et aux rejeux ? Décrivez votre pipeline de webhooks : diagnostic d'une page sous 48 h.
Recevoir mon diagnostic →Pourquoi répondre 2xx avant de faire le travail ?
Renvoyez 200 dès que l'événement est vérifié et enregistré, puis traitez-le depuis une file ou un job runner. Stripe attend une réponse rapide (un endpoint lent compte comme défaillant et déclenche des retries), et un travail lent en ligne (génération de PDF, e-mails, appels tiers) multiplie les doublons précisément quand votre système est déjà sous charge.
Une file entre l'endpoint et le traitement vous donne des retries que vous contrôlez, de l'amortissement pendant les pics et une destination de lettres mortes pour les événements qui échouent en boucle. Nous détaillons le schéma dans notre article sur les pipelines événementiels avec Lambda et SQS ; si vous vous demandez d'abord si une file se justifie, commencez par quand utiliser SQS.
Que faire des événements reçus dans le désordre ?
Stripe ne garantit pas l'ordre : invoice.paid peut arriver avant invoice.finalized, et un checkout.session.completed peut se présenter après les événements d'abonnement qu'il a déclenchés. Traitez l'événement comme le signal qu'une chose a changé, puis récupérez l'objet courant via l'API Stripe et agissez sur cet état, pas sur l'instantané embarqué dans la charge utile.
La charge utile est un instantané pris à la création de l'événement ; au moment où un retry tardif arrive, elle peut être périmée de plusieurs minutes ou de plusieurs jours. Un handler qui copie les champs de la charge utile dans la base locale finira par écraser un état frais avec des données anciennes. Récupérer l'objet par son identifiant avant d'agir coûte un appel d'API et élimine toute une classe de bugs ; cela rend aussi les handlers naturellement idempotents, puisque rejouer un événement relit le même état courant.
Quel filet de sécurité quand un webhook n'arrive jamais ?
Les webhooks sont un canal de notification, pas une source de vérité. Les endpoints tombent, les déploiements perdent du trafic, une route mal configurée avale des événements. Exécutez un job de réconciliation (horaire ou quotidien selon le volume) qui liste les événements récents ou les objets ouverts via l'API Stripe et retraite ce que votre base n'a pas vu. Ce job est le filet de sécurité.
Deux remarques pratiques. Stripe réessaie pendant trois jours au maximum en mode live et peut désactiver un endpoint qui échoue durablement (après vous avoir prévenu) : une panne plus longue que la fenêtre de retry perd donc des livraisons si la réconciliation n'existe pas. Et gardez ce job idempotent en le faisant passer par la même barrière de déduplication que le handler : la réconciliation ne doit jamais devenir une seconde source de doublons.
La checklist de production
- Montez express.raw (ou l'équivalent Fastify) sur la seule route du webhook ; vérifiez avec constructEvent à chaque requête.
- Insérez l'identifiant d'événement dans une table à contrainte d'unicité avant de traiter ; en cas de violation, répondez 200 et arrêtez.
- Répondez 2xx dès que l'événement est vérifié et enregistré ; exécutez le travail lent derrière une file.
- Récupérez l'objet courant via l'API Stripe avant d'agir ; ne vous fiez ni à la fraîcheur ni à l'ordre des charges utiles.
- Purgez les lignes de déduplication après environ 30 jours, en cohérence avec la rétention des événements chez Stripe.
- Faites tourner un cron de réconciliation contre l'API Stripe et alertez quand il trouve des événements que votre base a manqués.