Stripe retries failed webhook deliveries up to 3 days after the first failure. If your webhook handler uses plain db.create() or db.insert() rather than upserts, a retried event will either throw a unique constraint violation (silently dropping the event) or insert a duplicate row (corrupting subscription state). CWE-345 (Insufficient Verification of Data Authenticity) and ISO 25010 functional-correctness both apply. Handlers that perform slow synchronous operations (email sends, third-party API calls) before returning 200 also cause Stripe to time out and trigger additional retries, compounding the problem.
Medium because non-idempotent handlers corrupt subscription state on retry and can leave billing records permanently inconsistent.
Use upsert operations for all database writes in webhook handlers, and return 200 before performing any slow operations. Queue emails and third-party calls for background processing.
case 'customer.subscription.updated': {
const subscription = event.data.object
// upsert is safe to call multiple times with the same event
await db.user.upsert({
where: { stripe_customer_id: subscription.customer as string },
update: { subscription_status: subscription.status, plan: subscription.items.data[0]?.price.lookup_key ?? 'free' },
create: { /* ... */ }
})
// queue slow operations — don't block the 200 response
await queue.enqueue('subscription-changed-email', { customerId: subscription.customer })
return new Response('OK', { status: 200 }) // respond quickly
}
Alternatively, store processed event IDs in a processed_webhook_events table and skip re-processing if the event ID already exists.
ID: saas-billing.subscription-mgmt.webhook-retry-handling
Severity: medium
What to look for: Stripe (and other providers) will retry failed webhook deliveries multiple times. Check whether your webhook handler is idempotent — can it safely process the same event twice without corrupting state? Look for duplicate event protection mechanisms: checking whether an event ID has already been processed (storing processed event IDs), or using upsert operations instead of insert operations so re-processing has no harmful effect. Also check the webhook handler's response time — handlers that take too long will cause Stripe to time out and retry.
Pass criteria: Count every database write operation in webhook handlers. Webhook handler uses upsert operations (not pure inserts) for 100% of database writes, OR stores processed event IDs to prevent duplicate processing. The handler returns a 200 response quickly (before lengthy processing completes), using background processing for slow operations if needed.
Fail criteria: Webhook handler uses plain inserts that will fail or duplicate data on retry. Handler performs long-running synchronous operations that may time out before responding. No mechanism to handle duplicate event delivery.
Skip (N/A) when: No webhook endpoint detected.
Detail on fail: "Webhook handler uses db.create() — duplicate event delivery will fail with unique constraint or create duplicate records" or "Webhook handler performs 30s email send synchronously before responding — Stripe may time out and retry"
Remediation: Make webhook handlers idempotent with upserts and fast responses:
case 'customer.subscription.updated': {
const subscription = event.data.object
// Upsert is safe to call multiple times
await db.user.upsert({
where: { stripe_customer_id: subscription.customer as string },
update: { subscription_status: subscription.status },
create: { /* ... */ }
})
// Queue slow operations (emails, etc.) for background processing
await queue.enqueue('subscription-updated-email', { customerId: subscription.customer })
// Return 200 quickly
return new Response('OK', { status: 200 })
}