# Webhook handlers are idempotent

- **Pattern:** `ab-002609` (`project-snapshot.abuse.webhook-idempotency`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-25
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002609
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002609)

## 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 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.
- **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.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.
- **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:**
  ```sql
  CREATE TABLE webhook_events (event_id TEXT PRIMARY KEY, provider TEXT NOT NULL, processed_at TIMESTAMPTZ DEFAULT NOW());
  ```
  ```ts
  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: project-snapshot, abuse-resistance, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-002609
