# Webhook handlers are idempotent

- **Pattern:** `ab-000248` (`ai-slop-cost-bombs.job-hygiene.webhook-handlers-idempotent`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000248
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000248)

## Why it matters

Webhook providers (Stripe, Clerk, GitHub, Svix) retry delivery on any 5xx response — which is the correct behavior for eventual consistency. But a webhook handler without idempotency converts that reliability guarantee into a defect multiplier: a transient 500 during `payment.succeeded` processing means the payment is recorded twice, the fulfillment email sends twice, and the inventory decrements twice. For Stripe specifically, this can cause double charges in your records, triggering refund reconciliation issues that require manual intervention. Idempotency is not an optional hardening step — it is the contract that makes retries safe.

## Severity rationale

Critical because non-idempotent webhook handlers turn provider retry logic into a data corruption vector, causing duplicate charges, duplicate records, and fulfillment errors on every transient 5xx.

## Remediation

Use a `processedWebhook` table with a unique constraint on `eventId`. An atomic insert that rejects duplicates is the cleanest deduplication primitive — no Redis, no TTL expiry, and the processed set is queryable for debugging.

```ts
export async function POST(req: Request) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!
  const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)

  try {
    await prisma.processedWebhook.create({ data: { eventId: event.id } })
  } catch {
    // Unique constraint violation = already processed
    return Response.json({ ok: true, duplicate: true })
  }

  // Side effects only run once
  if (event.type === 'payment_intent.succeeded') {
    await prisma.payment.create({ data: { ... } })
  }
  return Response.json({ ok: true })
}
```

The `processedWebhooks` table needs only `eventId TEXT PRIMARY KEY` and `createdAt TIMESTAMP`. Add a cron to purge rows older than 30 days.

## Detection

- **ID:** `webhook-handlers-idempotent`
- **Severity:** `critical`
- **What to look for:** Walk all webhook handler files (under `app/api/webhooks/`, `pages/api/webhooks/`, `app/api/stripe/`, `app/api/clerk/`, `app/api/svix/`, OR any route file containing `webhook` in its path). Count all webhook handlers and verify each includes at least 1 idempotency pattern: a reference to `eventId`/`event_id`/`event.id`/`webhook_id` from the request body, a `db.X.findUnique({ where: { eventId } })` deduplication lookup, a Redis `SET NX`, a `processedWebhooks` table reference, an `idempotencyKey` value, OR the file imports `svix` (which handles idempotency at the verification layer).
- **Pass criteria:** 100% of webhook handler files implement idempotency via at least 1 of: `eventId` deduplication lookup, Redis `SET NX`, `processedWebhooks` table, `idempotencyKey` value, OR `svix` library import.
- **Report even on pass:** Always report the count and the idempotency mechanism. Example: "2 webhook handlers inspected, both use eventId dedup via processedWebhooks table."
- **Fail criteria:** At least 1 webhook handler processes events without an idempotency check.
- **Do NOT pass when:** A handler claims idempotency via a TODO comment but the actual code performs side effects unconditionally.
- **Skip (N/A) when:** Project has 0 webhook routes detected.
- **Cross-reference:** For broader webhook security analysis, the API Security audit (`api-security`) covers webhook signature verification.
- **Detail on fail:** `"2 non-idempotent webhooks: app/api/webhooks/stripe/route.ts processes payment_intent.succeeded without checking event.id (Stripe retries on 5xx — a hiccup means duplicate charges in your records), app/api/webhooks/clerk/route.ts creates user records without dedup"`
- **Remediation:** Webhook providers retry on 5xx responses — without idempotency, every retry creates duplicate side effects. Add deduplication:

  ```ts
  // Bad: processes every event, even duplicates
  export async function POST(req: Request) {
    const event = await req.json()
    if (event.type === 'payment_intent.succeeded') {
      await prisma.payment.create({ data: { ... } })
    }
  }

  // Good: dedupe by event ID
  export async function POST(req: Request) {
    const event = await req.json()
    // Atomic insert — fails on duplicate
    try {
      await prisma.processedWebhook.create({ data: { eventId: event.id } })
    } catch (e) {
      return Response.json({ ok: true, duplicate: true })  // already processed
    }
    if (event.type === 'payment_intent.succeeded') {
      await prisma.payment.create({ data: { ... } })
    }
    return Response.json({ ok: true })
  }
  ```

## External references

- iso-25010 reliability

Taxons: cost-efficiency, error-resilience

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