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'eventRenvoie "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 :
- Lire body (raw text —
constructEventa besoin du body brut) - Vérifier la signature avant tout
- Idempotence
- 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).