A webhook handler often does everything inside the request: verify the event, update the database, send the email, call another API, and only then answer. On a quiet day that finishes in time. On a busy one, or when a downstream service is slow, the response arrives late and Stripe's delivery log shows a timeout.
This post covers what that status means, what Stripe says the handler should do first, how to move the slow part into a queue, and how to catch up on events that failed in the meantime. Every claim links to the doc it comes from (see Sources at the end).
How to tell if the endpoint is timing out
- Open the webhook endpoint in the Stripe Dashboard and go to the Event deliveries tab. Stripe's docs describe it as a list of events and whether they're
Delivered,Pending, orFailed, and clicking an event shows "the HTTP status code of the delivery attempt and the time of pending future deliveries." - Look for the
(Timed out) ERRstatus. Stripe's description: "The destination server took too long to respond to the webhook request." - Compare the timed-out events with what the handler does before it responds. Anything slow that runs ahead of the response is a suspect.
What Stripe asks the handler to do
Stripe's fix for the timed-out status is: "Make sure you defer complex logic and return a successful response immediately in your webhook handling code."
The handler guidance says the same thing in more detail. The endpoint should be set up so that it:
- "Verifies the webhook request is generated by Stripe using the JSON payload, the
Stripe-Signatureheader, and thewhsec_webhook signing secret." - "Quickly returns a successful status code (
2xx) before any complex logic that might cause a timeout."
Stripe gives an example: "you must return a 200 response before updating a customer's invoice as paid in your accounting system."
So the order is: verify the signature, return 2xx, then do the work.
Why a slow response is worse than it looks
A timeout isn't a quiet event. Stripe counts it as a failed delivery and retries: "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode." Sandbox events are retried "three times over the course of a few hours."
Each retry is a second chance for the same work to run, so the handler can also see duplicates. Stripe notes that "Webhook endpoints might occasionally receive the same event more than once" and recommends "logging the event IDs you've processed, and then not processing already-logged events."
Hand the work to a queue
Stripe recommends asynchronous processing directly: "Configure your handler to process incoming events with an asynchronous queue." Its reasoning: "You might encounter scalability issues if you choose to process events synchronously. Any large spike in webhook deliveries (for example, during the beginning of the month when all subscriptions renew) might overwhelm your endpoint hosts." And: "Asynchronous queues allow you to process the concurrent events at a rate your system can support."
The handler's only jobs are to verify, store or enqueue, and answer.
// Verified webhook handler: verify, enqueue, answer. Nothing slow before the response.
export async function POST(req) {
const body = await req.text(); // raw body, unmodified, for signature verification
let event;
try {
event = stripe.webhooks.constructEvent(
body,
req.headers.get('stripe-signature'),
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response('bad signature', { status: 400 });
}
// Record the event ID; skip it if it's already been seen.
const isNew = await queue.enqueueOnce(event.id, event);
// The slow work (database writes, emails, third-party calls) runs in a worker.
return new Response('ok', { status: 200 });
}
// Worker, outside the request. Safe to run twice for the same event ID.
async function processEvent(event) {
if (await alreadyProcessed(event.id)) return;
await markProcessing(event.id);
// grant access, send the email, update the accounting system...
await markProcessed(event.id);
}
Two details from Stripe's docs matter here. First, the raw body: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body." Second, Stripe doesn't promise order: "Stripe doesn't guarantee the delivery of events in the order that they're generated," so the worker shouldn't depend on one event arriving before another.
Fix the failures that already happened
Stripe retries on its own, but a slow backlog can be cleared faster. For events that failed during an outage or slowdown, Stripe's docs say to call the List Events API with:
-
ending_before: "An event ID that was successfully delivered immediately before the outage." -
types: "The event types your endpoint listens for." -
delivery_success: "Set tofalseto return only events that failed delivery to at least one of your endpoints."
"Stripe only returns events created in the last 30 days." The response "returns up to 10 events" by default, and ending_before with auto-pagination "returns events in chronological order."
Manual processing has one catch: "Stripe still considers manually processed events as undelivered and continues its automatic retry schedule. When your endpoint receives an event you've already processed, skip it and return a 200 response to stop further retries." That's why the worker above checks the event ID before doing anything.
For single events, "In the Stripe Dashboard, click Resend when looking at a specific event. This works for up to 15 days after the event creation." The Stripe CLI works "for up to 30 days." Stripe adds that "Manually resending an event that failed to reach a webhook endpoint doesn't cancel Stripe's automatic retry behavior, even if it results in a 2xx status code."
Things that keep the timeout coming back
-
Work before the response. Any database write, email, or third-party API call ahead of
return 200can stretch the response past the limit. - Verification that isn't the problem. Signature checks belong before the 2xx, as Stripe lists, so keep them. Move everything after them.
- No queue under load. A burst, such as renewals at the start of the month, can overwhelm a handler that processes events inline.
- Wrong status on skipped events. If a duplicate is skipped but the handler doesn't answer 200, Stripe keeps retrying.
Checklist
- [ ] Event deliveries shows no
(Timed out)entries, or each one has been traced to slow work before the response - [ ] The handler verifies the signature on the raw body, then returns a
2xx - [ ] Nothing slow (database, email, third-party APIs) runs before the response
- [ ] Events go to an asynchronous queue and a worker processes them
- [ ] The worker logs event IDs and skips events it has already processed
- [ ] The handler doesn't depend on events arriving in a particular order
- [ ] Failed events from the last 30 days have been listed with
delivery_success=falseand processed
A timeout is one of several ways an endpoint can fail without anyone noticing. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide is here: Stripe webhook timed out?
WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-09.
Sources
- Stripe, Receive Stripe events in your webhook endpoint (handler steps,
(Timed out)status and fix, event deliveries, automatic and manual retries, event ordering, duplicate events, handle events asynchronously, quickly return a 2xx response): https://docs.stripe.com/webhooks - Stripe, Manage webhook endpoints (process undelivered events, List Events parameters, 30-day window, respond to automatic retries, async queue, quickly return a 2xx response): https://docs.stripe.com/events/manage-webhook-endpoints
Top comments (1)
One thing that changes once you return the 2xx first: Stripe stops retrying, so your queue becomes the only retry you have. That puts a lot of weight on the worker's
alreadyProcessed/markProcessing/markProcessedsteps.As written they can fail both ways. Two workers can both pass
alreadyProcessedbefore either marks it, so the email goes out twice. And ifalreadyProcessedalso treats "processing" as done, a worker that crashes aftermarkProcessingleaves the event stuck there forever, and nobody will ever resend it.The way I'd do it is make the claim one atomic statement with a lease, so a crashed worker's event comes back after the lease runs out:
Then
status = 'done'at the end, and something likeattempts > 5goes to a dead-letter state so you can alert on it. I checked the lease part in PGlite with a 1 second lease: the first claim gets the event, a second claim right away gets nothing, and after the lease expires it gets it again with attempts = 2. PGlite is a single connection, so that doesn't prove the skip-locked contention part, only the lease logic.