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:

Stripe does NOT retry when your endpoint:

Practical implication: always return 200, even when your downstream email send or DB write fails. Log the downstream failure separately and retry it on your own schedule with idempotency keyed on the Stripe event ID. Otherwise Stripe hammers your endpoint for 3 days and floods your error log.

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:

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:

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