TL;DR
Stripe retries a failed webhook delivery for up to three days (exponential
backoff) in live mode, then disables your endpoint and stops trying. If your
server is down or returning non-2xx for that whole window, the events are gone —
silently. The fix is to acknowledge Stripe immediately (2xx, durably), then do
your own retries on your own schedule. That way Stripe's window is never the
thing standing between you and your data.
The triggering symptom
One morning you look at a Stripe order that never hit your database. No error in
your logs, no crash report, nothing. You open the Stripe Dashboard → Developers →
Webhooks → your endpoint, and see a red "Disabled" badge next to it, with an
email in your inbox from days ago warning you the endpoint was failing.
That's the silent part: Stripe did retry. It just didn't retry forever, and when
it gave up it didn't hand the event back to you anywhere convenient.
The investigation path
Here's the order I'd check (and the order I did check the first time this
happened):
- Your server logs for the event ID. Nothing — because the requests never arrived (server was down) or arrived and were rejected before your logging code ran.
- Stripe Dashboard → your endpoint → Event deliveries. This is the thing most people skip. It shows every attempt, the HTTP status Stripe got back, and the timestamp of the next scheduled retry.
- The endpoint status. If it's "Disabled", Stripe auto-disabled it after continuous failure. Re-enabling it does not replay the missed events — it only accepts new ones.
-
Reproduce locally. Point a test endpoint at a server that returns
400for a specific event type and watch Stripe's retry behaviour (test mode retries 3 times over a few hours — fast enough to observe).
The code-level explanation
Stripe's retry policy (from the official docs) is:
- Live mode: retries for up to three days with exponential backoff. Stripe does not publish the exact intervals.
- Test/sandbox mode: retries three times over a few hours.
- A 2xx response stops retries; 3xx/4xx/5xx or a timeout schedules the next attempt.
- After continuous failure, Stripe disables the endpoint and emails you.
The failure mode that bites people is the 4xx trap. Here's a minimal Express
receiver that reproduces it — it returns 200 for known events and 400 for
anything else, which looks "safe" until an event type you didn't list arrives:
// receiver.js — the 4xx trap
const express = require("express");
const app = express();
const KNOWN = new Set(["payment_intent.succeeded", "checkout.session.completed"]);
app.post("/webhook", express.json(), (req, res) => {
const type = req.body?.type;
// Common mistake: reject anything "unknown" instead of acknowledging it
if (!KNOWN.has(type)) {
console.log(`rejecting ${type} with 400`);
return res.status(400).json({ error: "unknown event" });
}
// ... handle the known event ...
res.json({ received: true });
});
app.listen(3000);
Run it, fire an invoice.created event at it, and watch Stripe's Event
deliveries tab. You'll see:
attempt 1 → 400 (rejected) next retry: +5m
attempt 2 → 400 (rejected) next retry: +30m
attempt 3 → 400 (rejected) next retry: +2h
...
attempt N → 400 (rejected) endpoint disabled after ~3 days
Each retry that returns 400 burns another chunk of the window. The event is
not corrupt and your code is not throwing — it's just refusing to acknowledge,
which Stripe correctly treats as a failure. And a 400 looks so close to
"handled" that it never triggers an alert.
The second failure mode is the simpler one: your server is down through the
whole window. No 4xx, no logs — the requests never land. Three days later the
endpoint is disabled and the events are simply gone.
Two things to know about recovery:
-
Manual resend exists, but it's bounded: Stripe Dashboard "Resend" works up
to 15 days after creation;
stripe events resendvia CLI up to 30 days. Beyond that the event is unrecoverable. - Stripe regenerates the signature and timestamp on every attempt, so if you're verifying signatures (you should be), don't assume the timestamp equals event creation time.
The operational lesson
The core mistake is treating Stripe's retry window as your retry policy. It
isn't. It's a best-effort delivery loop with a hard ceiling, tuned for Stripe's
queue health, not for your outage windows.
What changed for us:
-
Acknowledge first, process second. Return
2xxas soon as the payload is durably accepted, before any business logic. If processing fails later, that's your problem to retry — but Stripe's window stops being the constraint. -
Own the retry schedule. A queue in front of your handler lets you set a
backoff that matches your infra, not Stripe's. Dead Letter, for example,
acknowledges the provider immediately and then retries the destination at
1m → 5m → 15m → 1h → 6h → 24h, then parks it in a dead-letter queue with one-click replay — so a 6-hour outage doesn't eat anyone's retry window. -
Alert on delivery failure, not on your own 5xx. If you only alert when
your code throws, the
400trap and the "server down" case both stay silent because neither produces a local stack trace.
Where a queue is not the right tool
Be honest with yourself here: if your only problem is the occasional missed event
and you're fine doing a manual "Resend" from the Dashboard or a stripe events reconciliation sweep, you don't need a queue — a cron job that backfills
list
from the Stripe API covers a lot. A queue earns its keep when you can't afford
the 3-day ceiling: high-volume checkout flows, endpoints that are down for hours
during deploys, or when you want signed, replayable payloads and per-event
diagnostics instead of Stripe's dashboard alone.
Try it
If a three-day retry ceiling is a liability for you, point your Stripe webhook at
a Dead Letter endpoint and keep your real URL secret — the 3-endpoint free tier
is enough to test with. Setup guide: https://app.deadletterhub.io/docs/guides/stripe
Top comments (0)