The email subject line is some variation of "Your webhook endpoint is failing," and if you ignored the first one, the follow-up says the endpoint has been disabled. Now nothing is arriving. Subscriptions aren't provisioning, invoices aren't reconciling, and you don't know how far back the damage goes.
The good news: Stripe keeps a copy of every event for 30 days, whether or not delivery succeeded. Almost everything is recoverable if you move inside that window. The bad news: most people re-enable the endpoint, see green checkmarks on new traffic, and never backfill the gap. The gap is the part that costs money.
TL;DR
- Re-enable the endpoint first, but that only resumes new events. It replays nothing
- Backfill with
GET /v1/events?delivery_success=false, which reaches back 30 days - Be idempotent before you backfill, or you will double-fulfil orders
- The root cause is nearly always a slow handler, not a broken one
- Persist the payload before you process it, and this stops being an emergency
Why Stripe disables endpoints at all
In live mode Stripe retries a failed delivery for up to three days with exponential backoff. That is a generous window, and it exists so a deploy or a brief outage costs you nothing. Stripe Webhook Retries: A Production Guide walks through that schedule and how to design for it before you reach the state this post is about.
An endpoint that fails continuously is a different signal. The address no longer works, and continuing to hammer it wastes Stripe's delivery capacity and buries your event log in noise. So Stripe warns you by email, tells you the date the endpoint will be turned off if nothing changes, and eventually turns it off.
Worth internalising: the disable is the second notification, not the first. By the time it happens, the failures have been visible in the Dashboard for days. If the warning email went to a founder inbox nobody reads, or to an alias that bounced, that's the actual bug.
Step 1: Find out what broke, before you re-enable
Resist the urge to click Enable immediately. If the underlying cause is still there, you will burn the retry window a second time.
In the Dashboard, go to Developers → Webhooks (or Workbench → Webhooks), open the endpoint, and look at recent deliveries. Stripe records the response it got for each attempt. You are looking for which of these you have:
| What you see | What it means |
|---|---|
Timed out | Your handler is too slow. Most common by a wide margin |
500 / 502 | Your code threw, or the platform in front of it did |
503 | Your app was down or scaled to zero and never woke up |
401 / 403 | Something in front of your app is blocking Stripe. WAF, Cloudflare rule, auth middleware |
404 | The route moved during a refactor and nobody updated the endpoint URL |
The 401/403 case catches people out after adding bot protection or a new auth layer, because webhook endpoints don't carry a session cookie and get treated as anonymous traffic. The 404 case usually shows up after a framework migration.
Fix that first. Then enable.
Step 2: Re-enable, and understand what that does not do
Re-enabling resumes delivery of events created from that moment forward. Events that were already given up on are not automatically re-sent. There's one narrow exception, documented by Stripe: if you disable and re-enable inside the original three-day retry window, in-flight retries still land. If the endpoint sat disabled for a week, nothing is coming back on its own.
So the state you're in right now is: new events flowing, historical gap untouched.
Step 3: Backfill from the Events API
This is the part people skip. Stripe's List Events API exposes every event from the last 30 days, and it takes a delivery_success filter:
curl -G https://api.stripe.com/v1/events \
-u "$STRIPE_SECRET_KEY:" \
-d delivery_success=false \
-d "types[]=payment_intent.succeeded" \
-d "types[]=customer.subscription.created" \
-d "types[]=invoice.paid" \
-d limit=100
A few things that matter here:
Filter by type, not by everything. A busy account generates a lot of event types you have no handler for. Pull the ones you actually process.
Use ending_before with auto-pagination. Pass the ID of an event from just before the outage started, and auto-pagination returns results in chronological order. That matters: customer.subscription.created should be processed before customer.subscription.updated, and the default ordering is newest-first.
const missed = await stripe.events.list({
delivery_success: false,
ending_before: "evt_lastKnownGood",
types: ["payment_intent.succeeded", "invoice.paid"],
limit: 100,
});
for await (const event of missed.autoPagingEach()) {
await handleEvent(event); // must be idempotent (see below)
}
The 30-day boundary is hard. Events older than that are gone from the API. If your endpoint was disabled five weeks ago, the first week is unrecoverable through Stripe and you're reconciling from the charges and subscriptions objects instead, which is a much worse afternoon.
Step 4: Be idempotent, or don't run the backfill
Your backfill will overlap with events Stripe already delivered successfully, and Stripe may still be retrying some of what you're now processing by hand. If handleEvent is not idempotent you will send two receipts, grant two license keys, or ship two orders.
The cheap version, and it is genuinely enough:
INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;
Zero rows affected means you have seen this event and should stop. Do this insert in the same transaction as the side effect where you can. If the side effect is an external API call that can't join your transaction, mark the row processing first, then processed after, so a crash mid-flight is visible rather than silently retried forever.
We wrote up the wider version of this problem, including out-of-order delivery and why at-least-once is a deliberate design choice, in You Received the Same Webhook Twice.
Step 5: Fix the thing that actually caused it
In our experience the disable is almost never caused by a handler that's wrong. It's caused by a handler that's slow. The pattern looks like this:
// The shape that eventually gets your endpoint disabled
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await db.insertOrder(event); // 40ms
await salesforce.sync(event); // 900ms, sometimes 8s
await resend.sendReceipt(event); // 300ms, sometimes times out
await slack.notify(event); // 200ms
return Response.json({ ok: true });
}
On a quiet Tuesday that's 1.5 seconds and nobody notices. When Salesforce has a bad afternoon it's 30 seconds, every delivery times out at once, and you're four days from a disabled endpoint. The dependency that failed isn't even yours.
The fix is structural, not a bigger timeout:
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await queue.publish(event); // durable write, ~10ms
return new Response(null, { status: 200 });
}
Accept, persist, acknowledge. Do the Salesforce sync in a worker that can retry on its own schedule without Stripe watching the clock. Now a slow third party is a slow job, not a delivery failure.
The other half is alerting. You want to know at the fifth consecutive failure, not on day three when Stripe emails a disable date. If the only monitoring you have is Stripe's own warning email, make very sure it goes somewhere a human reads.
Where AnyHook fits
AnyHook sits between Stripe and your server so that a slow origin can't become a disabled endpoint. Stripe talks to in.anyhook.net/you/stripe instead of your app, and we return 200 in under 50ms regardless of what your server is doing.
- Stripe never sees a timeout, so the disable path is never entered
- Every event is persisted with full headers and body, encrypted at rest, before any delivery is attempted
- Replay is a button. If your server was down from 14:00 to 16:00, replay that window. Replays don't consume quota, and they don't depend on Stripe's 30-day retention because the log is yours
- Failure-streak alerts at 1, 5, and 20 consecutive failures, with auto-pause at 20 so a broken deploy doesn't turn into a retry storm
The difference in practice: with a relay in front, the recovery playbook above collapses to selecting a time range and clicking replay. See How to Replay a Webhook When Your Server Was Down.
Takeaway
Re-enabling the endpoint is 10% of the recovery. The other 90% is backfilling the gap before the 30-day window closes, and making sure the handler is fast enough that Stripe never has a reason to disable it again. If your webhook handler calls a third-party API inline, you are already on the path. You just haven't had the bad afternoon yet.