Guides · Free check · $9 fix pack

Guides

Stripe webhook timed out (or returns 500): answer first, then do the work

The handler verifies the event, then sends the welcome email, generates a PDF, calls an AI API and updates three tables. Somewhere in there Stripe gives up waiting, marks the delivery as failed and tries again later, so the same work starts over. The fix is to change the order, not to make each step faster.

How to tell

Open your endpoint's Event deliveries in the Stripe Dashboard. Stripe lists "(Timed out) ERR" as "The destination server took too long to respond to the webhook request", and the fix it gives is: "Make sure you defer complex logic and return a successful response immediately in your webhook handling code" (Stripe docs: Fix HTTP status codes). A "(500) ERR" means "The destination server encountered an error while processing the request", so check your own logs for the exception (source).

Two side effects show up next: duplicates (the first attempt may have finished part of the work before the timeout, then the retry does it again) and a backlog of retries. In live mode Stripe retries "for up to three days with an exponential back off" (Stripe docs: Automatic retries).

The rule Stripe gives

"Your endpoint must quickly return a successful status code (2xx) before any complex logic that could cause a timeout. For example, you must return a 200 response before updating a customer's invoice as paid in your accounting system" (Stripe docs: Receive events). Stripe also recommends processing events "with an asynchronous queue", because "Any large spike in webhook deliveries (for example, during the beginning of the month when all subscriptions renew) might overwhelm your endpoint hosts" (Stripe docs: Handle events asynchronously). The docs don't put a number on "quickly", so don't aim for a limit: verify, record, answer.

The safe order

  1. Verify the signature on the raw body (signature errors). Reject with 400 if it fails.
  2. Record the event durably: insert event.id into a table with a unique constraint. Stripe says endpoints "might occasionally receive the same event more than once" and suggests "logging the event IDs you've processed, and then not processing already-logged events" (Stripe docs: Handle duplicate events). If the insert hits the unique constraint, it's a repeat: answer 200 and stop.
  3. Return 2xx.
  4. Do the slow work after the response, then mark the row processed.
  5. Sweep for leftovers. A scheduled job picks up rows that were recorded but never marked processed and runs them again.

Step 5 matters because once you've answered 2xx, Stripe counts the event as delivered and stops retrying it. If background work dies after that, the only copy of the job is the row you saved in step 2.

Next.js on Vercel: after() or waitUntil()

Next.js after "allows you to schedule work to be executed after a response", and works in Route Handlers; it "will run for the platform's default or configured max duration of your route" (Next.js docs: after). Vercel recommends after() on Next.js 15.1 and later instead of waitUntil() from @vercel/functions, and warns that "Promises passed to waitUntil() will have the same timeout as the function itself. If the function times out, the promises will be cancelled" (Vercel docs: @vercel/functions waitUntil). Duration is set with maxDuration (Vercel docs: Configuring max duration).

// app/api/webhooks/stripe/route.js
import Stripe from 'stripe';
import { after } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(req) {
  const body = await req.text(); // raw body, not req.json()
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }
  // Your own helper: INSERT ... ON CONFLICT (event_id) DO NOTHING, returns true if the row is new
  const isNew = await saveEventIfNew(event.id, event.type);
  if (isNew) after(() => processEvent(event.id)); // sets processed_at when done
  return new Response('ok', { status: 200 });
}

Supabase Edge Functions: EdgeRuntime.waitUntil()

Supabase: "You can use EdgeRuntime.waitUntil(promise) to explicitly mark background tasks. The Function instance continues to run until the promise provided to waitUntil completes", and calling it in the request handler "will not block the request". The work is still "capped based on the wall-clock, CPU, and memory limits" (Supabase docs: Background tasks, Supabase docs: Edge Function limits). Keep verify_jwt = false for the webhook function (401 guide).

// after constructEventAsync(...) succeeds and the event row is saved:
EdgeRuntime.waitUntil(processEvent(event.id));
return new Response('ok', { status: 200 });

Cloudflare Workers: ctx.waitUntil() or a Queue

ctx.waitUntil() lets a Worker keep working after it returns a response, but "For HTTP-triggered Workers, ctx.waitUntil() can extend execution for up to 30 seconds after the response is sent". For anything longer, Cloudflare says to "send messages to a Queue and process them in a separate consumer Worker" (Cloudflare docs: Context (ctx) waitUntil, Cloudflare docs: Queues).

What not to do

Clean up after the fix

Events that already failed keep retrying for up to three days; you can also resend one from the Dashboard (up to 15 days) or with stripe events resend (up to 30 days) (source). Because some of them may have partly run before timing out, the event-ID check above is what keeps the resends from doing the work twice (duplicate events guide).

Check yours in 10 seconds (free).

Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).

Run the free check

FAQ

What does (Timed out) ERR mean on a Stripe webhook?

Stripe waited for your endpoint and it took too long to answer. Stripe's fix is to defer complex logic and return a successful 2xx response immediately, then do the work.

Should my Stripe webhook return 200 before processing the event?

Yes, after verifying the signature and saving the event ID. Stripe says to return 2xx before any complex logic that could cause a timeout. Keep the saved event so a scheduled job can finish anything the background work dropped.

How do I run code after returning a response on Vercel or Supabase?

On Next.js use after() from next/server (Vercel also offers waitUntil() in @vercel/functions); on Supabase Edge Functions use EdgeRuntime.waitUntil(). Both are limited by the function's own time limits.

Why did a timeout create duplicate orders?

The first attempt can finish part of the work before Stripe gives up, then Stripe retries the same event. Record each event ID with a unique constraint and skip IDs you have already processed.

Sources

More guides

Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect

Last reviewed 2026-10-26. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.