skipToMainContentskipToFooter
NeuraAPI
NeuraAPI
Navigation
Tutoriel 25 août 2026 12 min de lecture

Webhook Stripe avec Next.js : tutoriel idempotent avec retry

Partager :

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 :

  1. Votre idempotence doit tenir sur plusieurs heures (d'où le stockage en base, pas en mémoire).
  2. Répondez 200 dè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.completed

Checklist 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.