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.
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.
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.
ID: ai-slop-cost-bombs.job-hygiene.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:
// 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 })
}