Stripe envoie des événements vers votre serveur à chaque changement d'état d'un paiement : confirmation, échec, remboursement, mise à jour d'abonnement. Sans webhooks Stripe correctement implémentés, votre application apprend ces transitions en retard — ou jamais — et des commandes peuvent s'activer sans paiement réel, ou ne jamais s'activer malgré un paiement réussi.
Deux erreurs reviennent systématiquement en production. La première : l'absence de vérification de signature, qui laisse la porte ouverte à des événements forgés. La deuxième : l'oubli de l'idempotence — Stripe peut envoyer le même événement plusieurs fois, et sans garde-fou, vous traitez deux fois la même commande, créez des doublons de factures ou envoyez plusieurs mails de confirmation.
Cet article couvre l'implémentation complète dans Next.js 15 App Router : Route Handler avec récupération du rawBody, vérification de signature, table d'idempotence Prisma, gestion des événements métier, codes HTTP et stratégie de retries, et tests locaux avec la Stripe CLI.

Pourquoi les webhooks Stripe sont différents d'un appel API classique
Un webhook n'est pas un appel que vous initiez — c'est Stripe qui frappe à votre porte à un moment que vous ne contrôlez pas. Trois conséquences directes pour votre implémentation.
N'importe qui peut frapper. Sans vérification de signature, un attaquant peut poster un faux événement charge.succeeded à votre endpoint et déclencher une activation de compte sans paiement. Stripe signe chaque requête avec votre STRIPE_WEBHOOK_SECRET et vous devez vérifier cette signature cryptographique avant toute logique métier.
Stripe peut envoyer le même événement plusieurs fois. En cas de timeout côté serveur (> 30 s) ou de réponse 5xx, Stripe retente l'envoi selon un backoff exponentiel pendant jusqu'à 3 jours. Si votre handler n'est pas idempotent, vous exécutez deux fois la même action métier.
L'ordre des événements n'est pas garanti. Pour un même objet Stripe, payment_intent.succeeded peut arriver après checkout.session.completed selon les conditions réseau. Votre logique ne doit jamais supposer un ordre particulier entre deux événements liés.
Récupérer le rawBody dans un Route Handler App Router
La vérification de signature Stripe opère sur le corps brut de la requête — un Buffer ou une string, pas du JSON parsé. C'est le premier piège du App Router : appeler request.json() consomme le stream et rend la vérification impossible.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
})
export async function POST(req: NextRequest) {
// req.text() lit le corps brut sans le parser — indispensable pour constructEventAsync
const rawBody = await req.text()
const signature = (await headers()).get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
}
let event: Stripe.Event
try {
// constructEventAsync est la version non-bloquante, compatible Edge Runtime
event = await stripe.webhooks.constructEventAsync(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch (err) {
// Ne jamais exposer les détails de l'erreur de signature dans la réponse
console.error('[stripe/webhook] Signature invalide:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
await handleStripeEvent(event)
// 200 indique à Stripe que l'événement a bien été reçu et traité
return NextResponse.json({ received: true })
}
constructEventAsync lève une exception si la signature est invalide ou si le timestamp est trop ancien (protection contre les attaques par replay). Retourner immédiatement un 400 est correct ici — Stripe ne retentera pas un événement rejeté pour signature invalide dans les mêmes conditions.
Idempotence : éviter le traitement en double des événements
Chaque événement Stripe a un id unique (ex. evt_1QxT2fKI...). L'idempotence consiste à persister cet id après traitement et à court-circuiter silencieusement tout doublon. Le schéma Prisma minimal :
model StripeEvent {
id String @id // event.id Stripe comme clé primaire naturelle
type String
processedAt DateTime @default(now())
}
La clé primaire est l'id Stripe lui-même — une tentative d'insertion dupliquée lèvera une contrainte d'unicité, ce qui est exactement le comportement souhaité.
async function handleStripeEvent(event: Stripe.Event) {
// Vérifier si l'événement a déjà été traité avant toute logique métier
const existing = await prisma.stripeEvent.findUnique({
where: { id: event.id },
})
if (existing) {
// Retour silencieux — le 200 final indique à Stripe de cesser les retries
return
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
// Ignorer silencieusement les types non gérés — ne jamais lever d'exception
default:
break
}
// Marquer comme traité APRÈS la logique métier, pas avant
// Si la logique échoue, l'événement n'est pas marqué → Stripe peut renvoyer
await prisma.stripeEvent.create({
data: { id: event.id, type: event.type },
})
}
L'ordre des opérations est délibéré : persistance après la logique, pas avant. Si on inverse, une erreur en cours de traitement produit un événement marqué "traité" sans que l'action ait réellement eu lieu — le pire des scénarios sur un flux de paiement.
Gérer les événements métier : checkout, subscriptions, échecs
Les trois événements les plus courants sur une application SaaS.
checkout.session.completed est déclenché quand un utilisateur finalise le tunnel de paiement. C'est ici que vous activez l'accès, créez la commande en base et envoyez le mail de confirmation. Passez l'identifiant utilisateur dans session.metadata au moment de créer la session côté Stripe, pour pouvoir le récupérer ici.
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId
if (!userId) throw new Error('Missing userId in checkout session metadata')
await prisma.user.update({
where: { id: userId },
data: {
plan: 'pro',
stripeCustomerId: session.customer as string,
},
})
}
customer.subscription.updated couvre les changements de plan, les renouvellements et le passage en past_due. Préférez récupérer l'objet frais depuis l'API Stripe plutôt que de vous fier uniquement à event.data.object — certains champs peuvent être partiels selon la version d'API configurée sur votre compte.
invoice.payment_failed signale un échec de prélèvement. Notifiez l'utilisateur, mais ne désactivez pas l'accès immédiatement : Stripe retentera les prélèvements automatiquement et peut envoyer un invoice.payment_succeeded quelques jours plus tard.
Codes HTTP, retries et gestion des erreurs
Stripe considère un webhook réussi si votre endpoint répond 2xx dans les 30 secondes. En cas d'échec, il retente selon ce calendrier : 5 min → 30 min → 2h → 5h → 10h → toutes les 12h jusqu'à 3 jours.
La règle d'or : ne retournez jamais 5xx si vous avez reconnu l'événement. Un 500 déclenche des retries inutiles. Si une étape non critique échoue — envoi d'email, notification Slack — loggez l'erreur mais retournez quand même 200.
Retournez 400 uniquement pour les cas irrécupérables : signature invalide ou absente, corps malformé, ou champ metadata obligatoire manquant.
Tester en local avec la Stripe CLI
La Stripe CLI forwarde les événements Stripe vers votre serveur local en temps réel — indispensable pour développer sans exposer un serveur public.
# Installation macOS
brew install stripe/stripe-cli/stripe
stripe login
# Forwarder les webhooks vers votre dev server
# La commande affiche le STRIPE_WEBHOOK_SECRET temporaire à copier dans .env.local
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Déclencher des événements de test
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
# Tester l'idempotence en renvoyant un événement existant
stripe events resend evt_1QxT2fKI...
En mode listen, la CLI affiche chaque événement, le code HTTP retourné et le corps de réponse, ce qui permet de déboguer l'ensemble du flux sans infrastructure externe.
En pratique
Dans les projets Kreio, les webhooks Stripe suivent systématiquement le même pattern : l'endpoint vérifie la signature, déduplique via l'event id, puis push dans une queue Inngest. Toute la logique métier vit dans les workers — ce qui rend chaque étape testable indépendamment et découplée des contraintes de timeout de Stripe.
Pour optimiser les requêtes Prisma qui persistent les événements et mettent à jour les statuts utilisateurs, voir Optimiser ses requêtes Prisma dans Next.js. Pour protéger les routes qui consomment les accès activés par ces webhooks, l'article sur l'authentification Next.js App Router couvre les patterns de protection par plan.
Chez Kreio, agence Next.js basée à Évreux (Normandie), on applique ces patterns sur des projets clients SaaS en production. Besoin d'un audit de votre intégration Stripe ou d'un renfort technique ? Parlons-en.
Sources
- Stripe — Webhooks best practices — Stripe Docs
- Stripe — Check webhook signatures — Stripe Docs
- Stripe CLI — Test webhooks locally — Stripe Docs