Webhook Stripe avec Next.js : tutoriel idempotent avec retry
Le webhook est la colonne vertébrale de tout système de paiement : sans lui, aucun moyen fiable de savoir qu'un paiement a réussi. Mal écrit, il provoque des doublons de facturation et des états incohérents. Voici comment faire correctement.
Le problème : au moins une fois, jamais exactement une fois
Stripe garantit l'at-least-once delivery : si votre endpoint répond lentement ou en erreur, le même événement peut arriver plusieurs fois. Votre handler doit donc être idempotent — traiter deux fois le même événement produit le même résultat que le traiter une fois.
Étape 1 : La route et la vérification de signature
Point crucial : il faut lire le corps brut de la requête. Ne faites jamais await req.json() avant la vérification.
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import stripeLib from 'stripe'
const stripe = new stripeLib(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: NextRequest) {
const body = await req.text() // Corps BRUT
const signature = req.headers.get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('Signature invalide:', err)
return NextResponse.json({ error: 'invalid_signature' }, { status: 400 })
}
// ... traitement
return NextResponse.json({ received: true })
}Étape 2 : L'idempotence par upsert
Stockez chaque ID d'événement traité dans votre base. Si l'insertion échoue (déjà présent), vous avez déjà traité cet événement : sortez tôt.
// Prisma : modèle dédié
model WebhookEvent {
id String @id // c'est event.id de Stripe
type String
receivedAt DateTime @default(now())
}async function processEvent(event: stripe.Event) {
// Idempotence : upsert atomique
try {
await db.webhookEvent.create({
data: { id: event.id, type: event.type },
})
} catch {
// Événement déjà traité → no-op silencieux
return { duplicate: true }
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as stripe.Checkout.Session
await fulfillOrder(session)
break
}
case 'customer.subscription.deleted':
await revokeAccess(event.data.object as stripe.Subscription)
break
}
return { duplicate: false }
}En SQL pur, un INSERT ... ON CONFLICT DO NOTHING RETURNING donne le même effet atomique en une seule requête.
Étape 3 : Répondre vite, traiter lentement
Stripe attend une réponse en moins de 20 secondes. Si votre handler fait des appels lents (IA, emails, exports), répondez immédiatement et déportez le travail :
- File d'attente : Vercel Queues, Upstash QStash ou Inngest.
- Ou traitement fire-and-forget avec
waitUntil()sur Vercel.
Étape 4 : Comprendre les retries de Stripe
En cas d'échec, Stripe retente selon un backoff exponentiel pendant 72 heures : environ 1 min, 5 min, 30 min, puis toutes les heures jusqu'à épuisement. Deux conséquences :
- Votre idempotence doit tenir sur plusieurs heures (d'où le stockage en base, pas en mémoire).
- Répondez
200dès que l'événement est persisté, même si son traitement métier est encore en file.
Pour vos propres appels vers des services externes depuis un handler, implémentez aussi un retry local :
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
if (i === attempts - 1) throw err
await new Promise(r => setTimeout(r, 2 ** i * 500))
}
}
throw new Error('unreachable')
}Test en local avec la CLI
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Copiez le whsec_... affiché dans STRIPE_WEBHOOK_SECRET
stripe trigger checkout.session.completedChecklist production
- Signature vérifiée sur chaque requête, avec tolérance de temps par défaut (5 min).
- Table d'événements pour l'idempotence durable.
- Réponse < 20 s, traitement lourd en file.
- Monitoring du dashboard Stripe → Developers → Webhooks : taux d'échec > 1 % = investiguez.
- Endpoint séparés par usage si vous avez des volumes très différents (paiements vs facturation).
Ne codez pas ça à la main
Nos templates SaaS incluent les webhooks Stripe complets : signature, idempotence, gestion des abonnements. Voir les templates
Conclusion
Un webhook Stripe robuste tient en trois principes : corps brut + signature, table d'idempotence, réponse rapide. Avec ces fondations, vos paiements resteront cohérents même quand Stripe retente dix fois le même événement. Pour aller plus vite, cette architecture est déjà prête dans nos templates.