Webhook handlers are idempotent
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.
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 containingwebhookin its path). Count all webhook handlers and verify each includes at least 1 idempotency pattern: a reference toeventId/event_id/event.id/webhook_idfrom the request body, adb.X.findUnique({ where: { eventId } })deduplication lookup, a RedisSET NX, aprocessedWebhookstable reference, anidempotencyKeyvalue, OR the file importssvix(which handles idempotency at the verification layer). -
Pass criteria: 100% of webhook handler files implement idempotency via at least 1 of:
eventIddeduplication lookup, RedisSET NX,processedWebhookstable,idempotencyKeyvalue, ORsvixlibrary 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:
// 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:2011 · reliability
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ai-slop-cost-bombs·automated