Stripe Webhook Retry Behavior: How Long, How Often, and What Triggers It
If your webhook returns a non-2xx response, Stripe retries. For 3 days. On an exponential backoff. Then it eventually disables your endpoint. Below: the exact schedule, what counts as a failure, when Stripe gives up, and how to recover events you dropped during downtime.
The exact retry schedule
Stripe retries failed webhook deliveries for up to 3 days, with backoff intervals roughly:
| Attempt | Time after first delivery |
|---|---|
| 1 (initial) | 0 seconds |
| 2 | ~5 minutes |
| 3 | ~30 minutes |
| 4 | ~2 hours |
| 5 | ~4 hours |
| 6 | ~8 hours |
| 7 | ~16 hours |
| 8 and beyond | ~daily, up to 72 hours total |
The exact intervals can shift slightly — Stripe documents the schedule as "best effort" — but the pattern is consistent: a few quick retries, then exponential backoff, then daily attempts until the 72-hour window closes.
What counts as a failure (and what doesn't)
Stripe retries when your endpoint:
- Returns any HTTP status code outside the 2xx range (200, 201, 202, etc.)
- Times out — Stripe waits up to 30 seconds for your response
- Returns a non-HTTP response (connection refused, DNS failure, TLS handshake failure)
Stripe does NOT retry when your endpoint:
- Returns 200 with any body (even
{"received": true, "error": "downstream failed"}— that 200 is the contract) - Returns 410 Gone — Stripe interprets this as "permanently delete this event from queue"
The "Stripe disables your endpoint" rule
If too many consecutive deliveries fail, Stripe disables the webhook endpoint entirely. The exact threshold isn't publicly documented, but the empirical observation across multiple sources is:
- If all deliveries during a multi-day window fail (e.g. your endpoint has been 500ing for 3+ days straight), Stripe disables the endpoint
- You receive an email at the email registered on the Stripe account: "Your webhook endpoint has been disabled"
- The endpoint stays disabled until you manually re-enable it in the Stripe Dashboard
- While disabled, Stripe stops sending events to that endpoint entirely — you don't get the events back when you re-enable
Translation: a webhook outage that lasts more than ~72 hours risks permanent event loss for that window.
How to recover dropped events
If you fixed your webhook after a multi-day outage and need to reprocess events Stripe delivered (or tried to deliver) during downtime, you have two options:
Option 1 — Replay from Stripe Dashboard
Stripe Dashboard → Developers → Webhooks → click your endpoint → "Recent deliveries" tab. Each row shows attempt count and last response. For events you want to retry, click into the event and use "Resend" to fire it at your endpoint again.
This works well for 10 or fewer events. For more, you need API access.
Option 2 — Replay via Stripe API
List events from the API and replay them through your handler:
// Pseudo-code; needs your secret key + a way to call your webhook handler
const events = await stripe.events.list({
type: 'checkout.session.completed',
created: { gte: outage_start_unix, lte: outage_end_unix },
limit: 100,
});
for (const event of events.data) {
// Re-enqueue through your handler, keyed by event.id for idempotency
await yourWebhookHandler({ body: event });
}
Note: events.list returns events from your Stripe API logs, not from the webhook delivery queue. If Stripe stopped trying to deliver because your endpoint was disabled, the events are still in your API log — they just weren't delivered. The replay loop above pulls them from the log and processes them locally.
What about events Stripe never tried to deliver?
If your endpoint was registered and enabled, Stripe always attempts delivery for matching event types. The events.list query above retrieves the same events Stripe attempted to send — so even if your endpoint was 500ing, the events are recoverable.
Events that fall outside your subscribed event types are NOT retroactively delivered. If you added checkout.session.async_payment_succeeded to your webhook configuration today, you cannot retrieve past events of that type via a delivery replay; you'd need to re-process them manually from events.list.
The 400 noise pattern (most common confusion)
If you read your Vercel/Render/Cloudflare logs and see a stream of 400 responses on your webhook endpoint, the most common cause is NOT live event-delivery failure. It's Stripe retrying old events that failed deliveries from before your last fix.
Empirical pattern: after fixing a webhook secret rotation or signature-verification bug, expect 400 noise for ~72 hours as Stripe drains its retry queue of stale events. Each old failed delivery retries on the schedule above. They will all 400 (because the timestamp is out of tolerance, or the secret was different at original delivery time). They will fade.
Do NOT panic-rotate your webhook secret in response to 400 noise. That makes the problem worse. Identify whether the 400s are on new events or retries of old events:
// Log the timestamp delta in your handler
const ts = parseInt(parsedSigHeader.t, 10);
const ageSeconds = Math.floor(Date.now() / 1000) - ts;
console.log(`webhook event age: ${ageSeconds}s`);
If the age is in the thousands (or tens of thousands) of seconds, those are stale retries and they'll go away. If the age is < 60 seconds and you're still 400ing, that's a real bug — probably a fresh secret mismatch or body-parser issue.
How to monitor retry health
Stripe Dashboard → Developers → Webhooks → click endpoint → top of page shows aggregate success rate over the last 24h / 7d / 30d. Green is "all events delivered successfully on first or retry attempt." Red is "some events failed final delivery attempts."
For deeper visibility, set up an alert on:
- Aggregate failure rate > 5% for any 1-hour window — early warning of secret/body-parser bug
- Any single event with attempt count ≥ 3 and not yet delivered — escalation candidate
- Any "endpoint disabled" event from Stripe — page yourself
The "always 200" code pattern
export default async function handler(req, res) {
// sig verification...
if (!verifySignature(rawBody, sigHeader, secret)) {
return res.status(400).json({ error: 'Invalid signature' });
}
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
// Filter for the type we care about
if (event.type !== 'checkout.session.completed') {
return res.status(200).json({ received: true, ignored: event.type });
}
// Do the work, but never fail to Stripe even if downstream fails
try {
await sendCustomerEmail(event.data.object);
} catch (e) {
console.error('email send failed', e);
// Note in your error monitor; retry yourself; do NOT 500 to Stripe
return res.status(200).json({ received: true, email_error: String(e) });
}
return res.status(200).json({ received: true });
}
This is the pattern. 400 only on signature failure or invalid JSON (those are real bugs). 200 on everything else, including downstream failures, with the failure logged separately for your own retry pipeline.
Want this pattern shipped to your repo without writing it yourself?
$199 flat (first 3 clients, then $399). Includes idempotency, downstream-fail handling, sig verification, customer + founder emails. Verified live before delivery.
See the offer →Related
- Why your Stripe webhook returns 400 in production — the 4 root causes
- Why Stripe fires 6+ events per checkout — multi-event handling
- Free webhook signature diagnostic — paste your sig + body, get verdict
- Webhook uptime monitoring — $29/mo