Webhook handlers are idempotent
Why it matters
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.
Severity rationale
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.
Remediation
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.
Detection
- ID:
webhook-idempotency - Severity:
medium - What to look for: Enumerate webhook handlers — routes under
app/api/webhooks/**/route.ts,pages/api/webhooks/*, handlers acceptingStripe-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 NOTHINGwith 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 = ?thenINSERT, early-returning if the select found a row; (d) RedisSETNX webhook:<id> 1branching on key-already-existed. The dedupe key MUST come from the provider (Stripeevent.id, GitHubX-GitHub-Delivery, Svixsvix-id), not a timestamp or body hash. - Pass criteria: Every handler uses exactly one of the four mechanisms with a provider-supplied event id, and early-returns on duplicate before side-effects.
- Fail criteria: Handler processes events without a listed mechanism AND has non-idempotent side-effects (inserts, email sends, credit grants, counter increments, entitlement flips). A
prisma.x.upsertthat 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. - Skip (N/A) when: No webhook handlers at all. Quote:
"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". - Report even on pass: Name mechanism + file:line per handler:
"/api/webhooks/stripe/route.ts:14 ON CONFLICT DO NOTHING on webhook_events(event_id), early-returns at :19". - Detail on fail:
"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). - Cross-reference: For exactly-once delivery, DLQs, and retry-observability dashboards, run
api-security. - Remediation:
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 }); }
Taxons
History
- 2026-04-22·v1.0.0·Initial authoring via Phase 9 consequence-first restructure·by editorial
- 2026-04-23·v1.1.0·Phase 9.1 tighten — "naturally idempotent" handwave no longer counts; only 4 explicit mechanisms qualify (ON CONFLICT DO NOTHING / upsert+existence-check / dedupe-table SELECT+INSERT / Redis SETNX).·by phase-9-1-stack-scan-v3-1
- 2026-04-25·v1.1.1·v3.1.0 pre-ship trim — prose compression for under-80K MCP cap; merged overlapping Fail-criteria / Do-NOT-pass-when sections; compressed enumeration prose; one remediation example per pattern. No semantic change; anti-sycophancy guards preserved.·by phase-9-1-stack-scan-v3-1