Stripe retries webhook deliveries on any non-2xx response or timeout, and the retry schedule extends across up to 3 days of exponential backoff for a single event. Supabase, GitHub, Resend, and nearly every other webhook-emitting platform follow the same "retry until ACK" contract. Without an idempotency check, each retry causes your handler to reprocess the same event: users get double-charged, subscriptions grant the same product twice, counters advance twice, confirmation emails arrive twice, and audit logs record ghost transactions. This is the canonical "discovered 30 days after launch when a brief outage causes a Stripe retry storm" footgun. AI coding tools scaffold webhook handlers that work perfectly in happy-path testing — one event in, one database write out — and completely miss the retry semantics that only appear in production under load or during provider-side delivery delays. Stripe's documentation warns about this explicitly under "Handle events asynchronously," but the warning doesn't survive the scaffolding transplant.
Medium because the failure mode requires an actual retry event (not constant) but when it fires it causes real financial damage (double-charges, duplicate grants, corrupted counters) and is typically discovered only after customer complaints surface in production.
Persist a webhook_events(event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ) table. On every webhook invocation, extract the provider's event id (event.id for Stripe, delivery or X-GitHub-Delivery header for GitHub), attempt INSERT ... ON CONFLICT (event_id) DO NOTHING, and short-circuit with a 200 when the row already exists. Wrap the side-effects in a DB transaction alongside the insert so partial processing can't orphan an idempotency marker. See the api-security Pro audit for exactly-once delivery patterns and retry-observability dashboards.
project-snapshot.abuse.webhook-idempotencymediumapp/api/webhooks/**/route.ts, pages/api/webhooks/*, handlers accepting Stripe-Signature, X-GitHub-Delivery, X-Supabase-Signature, svix-id, X-Hub-Signature-256. For each, confirm ONE of four explicit dedupe mechanisms runs before any non-idempotent side-effect: (a) INSERT ... ON CONFLICT (event_id) DO NOTHING with a branch on conflict/rowcount; (b) prisma.x.upsert({ where: { event_id } }) followed by an existence check that early-returns when the row pre-existed; (c) SELECT 1 FROM webhook_events WHERE event_id = ? then INSERT, early-returning if the select found a row; (d) Redis SETNX webhook:<id> 1 branching on key-already-existed. The dedupe key MUST come from the provider (Stripe event.id, GitHub X-GitHub-Delivery, Svix svix-id), not a timestamp or body hash.prisma.x.upsert that UPDATES on every delivery does NOT count — upsert alone is not idempotent for side-effects outside the row (emails, external API calls, counters elsewhere). Comment-only TODOs, "naturally idempotent because syncSubscription always re-reads Stripe state" handwaves, and signature-verified-but-not-deduped handlers all fail — a verified event still arrives twice on retry."no app/api/webhooks/* routes, no handlers accept Stripe-Signature / svix-id / X-GitHub-Delivery / X-Supabase-Signature; no Stripe / @octokit/webhooks / svix in deps"."/api/webhooks/stripe/route.ts:14 ON CONFLICT DO NOTHING on webhook_events(event_id), early-returns at :19"."app/api/webhooks/stripe/route.ts:18 processes checkout.session.completed with prisma.orders.create and no event-id dedupe; retries (up to 3 days) will duplicate orders + emails" (≤500 chars).api-security.CREATE TABLE webhook_events (event_id TEXT PRIMARY KEY, provider TEXT NOT NULL, processed_at TIMESTAMPTZ DEFAULT NOW());
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, secret);
const { error } = await supabase.from('webhook_events').insert({ event_id: event.id, provider: 'stripe' });
if (error?.code === '23505') return new Response('ok', { status: 200 }); // duplicate
await processEvent(event);
return new Response('ok', { status: 200 });
}