Documentation

Idempotence des webhooks

Stripe et Resend retentent en cas d'erreur — pattern `claimWebhookEvent` pour éviter les double-traitements.

Stripe (et plein d'autres webhooks providers) retentent un event en cas de 5xx ou de network failure. Sans guard, l'event peut être traité 2-3 fois → double email, double audit log, double charge analytics.

Le helper

lib/webhook-idempotence.ts :

import { claimWebhookEvent } from "@/lib/webhook-idempotence";

if ((await claimWebhookEvent(event.id, event.type)) === "duplicate") {
  return NextResponse.json({ received: true, duplicate: true });
}
// ... traitement de l'event

Renvoie "fresh" la première fois, "duplicate" ensuite. S'appuie sur le modèle StripeWebhookEvent (unique sur id) :

model StripeWebhookEvent {
  id        String   @id      // = event.id Stripe
  type      String
  createdAt DateTime @default(now())
}

La création unique est utilisée comme verrou : si P2002, l'event était déjà reçu, on ack juste pour que Stripe arrête de retry.

Pattern complet (Stripe)

export async function POST(req: Request) {
  const body = await req.text();
  const signature = (await headers()).get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: "Signature invalide" }, { status: 400 });
  }

  if ((await claimWebhookEvent(event.id, event.type)) === "duplicate") {
    return NextResponse.json({ received: true, duplicate: true });
  }

  switch (event.type) {
    case "checkout.session.completed": { /* ... */ }
    // etc.
  }

  return NextResponse.json({ received: true });
}

Ordre crucial :

  1. Lire body (raw text — constructEvent a besoin du body brut)
  2. Vérifier la signature avant tout
  3. Idempotence
  4. Switch sur event.type

Pour Resend / autres providers

Le pattern marche pour n'importe quel webhook qui expose un id unique et stable par event. Crée juste une table dédiée si tu veux les séparer (ResendWebhookEvent, etc.) ou élargis StripeWebhookEvent en WebhookEvent avec une colonne provider.

Pour Resend, l'id est event.data.email_id. Vu qu'il référence un email et non un event, plusieurs events peuvent avoir le même email_id (delivered → opened sur le même envoi). On n'utilise pas claimWebhookEvent côté Resend — la dédup naturelle se fait via les colonnes deliveredAt / bouncedAt / openedAt de EmailLog qui ne sont set qu'une seule fois.

Tests

Couvert par lib/webhook-idempotence.test.ts (4 tests : fresh, duplicate, P2002 catch, non-P2002 rethrow).

On this page