Stripe's webhook delivery has a 30-second timeout per attempt. If your webhook handler performs multiple sequential database writes, sends email via an external API, updates inventory, and calls third-party fulfillment services before returning 200, the total latency can easily exceed 30 seconds under normal load — and will exceed it under any database contention or external service slowdown. Stripe then marks the delivery as failed and retries, causing your handler to run again on the same event. Without idempotency guards, these retries create duplicate orders, duplicate emails, and duplicate inventory decrements. CWE-400 (Uncontrolled Resource Consumption) captures the timeout-induced retry cascade.
Low because excessive synchronous processing before returning 200 causes Stripe to retry the webhook, and without idempotency guards those retries produce duplicate orders and emails.
Return 200 immediately after signature verification, then dispatch heavy work to a job queue for asynchronous processing.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text()
const sig = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// Enqueue for async processing — lightweight, unlikely to timeout
await queue.enqueue({ type: 'stripe-event', event })
return new Response('OK', { status: 200 }) // Stripe receives 200 within milliseconds
}
Use Inngest, QStash, or BullMQ for the queue. The queue worker handles database writes, email, and fulfillment with its own retry logic — separate from Stripe's retry mechanism.
ID: ecommerce-payment-security.payment-errors.webhook-quick-response
Severity: low
What to look for: Examine webhook handler code execution order. Count all blocking operations (database writes, external API calls, email sends, inventory updates) that execute before the HTTP response is returned. For each webhook handler, list all await calls between the signature verification and the return new Response() statement. Payment providers time out after 30 seconds (Stripe) and will retry if a 200 is not received promptly — no more than 2 lightweight await calls should precede the response.
Pass criteria: Webhook handlers verify the signature, acknowledge receipt with a 200 response quickly, and either: (a) perform no more than 2 lightweight synchronous operations before responding, or (b) queue heavier processing (email, inventory, fulfillment) for asynchronous execution after returning 200.
Fail criteria: Webhook handlers perform heavy synchronous operations (at least 3 blocking await calls including database queries, external email sends, or sequential API calls) before returning the HTTP response, creating a risk of timeout and retry storms.
Skip (N/A) when: The project has no webhook handlers implemented. Confirm by checking for absence of webhook route files.
Detail on fail: Describe the slow operations. Example: "Stripe webhook handler at app/api/webhooks/stripe/route.ts sends a confirmation email and updates three database tables synchronously before returning 200 — total processing time could exceed 30 seconds, triggering Stripe retries"
Remediation: Return 200 immediately after signature verification, then process asynchronously:
export async function POST(req: Request) {
const body = await req.text()
const sig = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// Respond immediately — before any complex processing
// Queue the work for async processing
await queue.enqueue({ type: 'process-stripe-event', event })
return new Response('OK', { status: 200 })
}
Use a job queue (Inngest, QStash, BullMQ, Upstash Workflow) for the heavy lifting.