<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sy Babayev</title>
    <description>The latest articles on DEV Community by Sy Babayev (@sy_babayev_faaaca67391beb).</description>
    <link>https://dev.to/sy_babayev_faaaca67391beb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2867370%2F785fe46f-fd93-416a-a0fb-e5c2eb3dff0d.jpg</url>
      <title>DEV Community: Sy Babayev</title>
      <link>https://dev.to/sy_babayev_faaaca67391beb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sy_babayev_faaaca67391beb"/>
    <language>en</language>
    <item>
      <title>Why Your Stripe Webhooks Vanish After the 3-Day Retry Window (And How to Fix It)</title>
      <dc:creator>Sy Babayev</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:01:17 +0000</pubDate>
      <link>https://dev.to/sy_babayev_faaaca67391beb/why-your-stripe-webhooks-vanish-after-the-3-day-retry-window-and-how-to-fix-it-17mj</link>
      <guid>https://dev.to/sy_babayev_faaaca67391beb/why-your-stripe-webhooks-vanish-after-the-3-day-retry-window-and-how-to-fix-it-17mj</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Stripe retries a failed webhook delivery for &lt;strong&gt;up to three days&lt;/strong&gt; (exponential&lt;br&gt;
backoff) in live mode, then &lt;strong&gt;disables your endpoint&lt;/strong&gt; and stops trying. If your&lt;br&gt;
server is down or returning non-2xx for that whole window, the events are gone —&lt;br&gt;
silently. The fix is to acknowledge Stripe immediately (2xx, durably), then do&lt;br&gt;
your own retries on your own schedule. That way Stripe's window is never the&lt;br&gt;
thing standing between you and your data.&lt;/p&gt;
&lt;h2&gt;
  
  
  The triggering symptom
&lt;/h2&gt;

&lt;p&gt;One morning you look at a Stripe order that never hit your database. No error in&lt;br&gt;
your logs, no crash report, nothing. You open the Stripe Dashboard → Developers →&lt;br&gt;
Webhooks → your endpoint, and see a red "Disabled" badge next to it, with an&lt;br&gt;
email in your inbox from days ago warning you the endpoint was failing.&lt;/p&gt;

&lt;p&gt;That's the silent part: Stripe did retry. It just didn't retry forever, and when&lt;br&gt;
it gave up it didn't hand the event back to you anywhere convenient.&lt;/p&gt;
&lt;h2&gt;
  
  
  The investigation path
&lt;/h2&gt;

&lt;p&gt;Here's the order I'd check (and the order I did check the first time this&lt;br&gt;
happened):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your server logs for the event ID.&lt;/strong&gt; Nothing — because the requests never
arrived (server was down) or arrived and were rejected before your logging
code ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe Dashboard → your endpoint → Event deliveries.&lt;/strong&gt; This is the thing
most people skip. It shows every attempt, the HTTP status Stripe got back, and
the timestamp of the &lt;em&gt;next&lt;/em&gt; scheduled retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint status.&lt;/strong&gt; If it's "Disabled", Stripe auto-disabled it after
continuous failure. Re-enabling it does &lt;strong&gt;not&lt;/strong&gt; replay the missed events — it
only accepts new ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce locally.&lt;/strong&gt; Point a test endpoint at a server that returns &lt;code&gt;400&lt;/code&gt;
for a specific event type and watch Stripe's retry behaviour (test mode
retries 3 times over a few hours — fast enough to observe).&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The code-level explanation
&lt;/h2&gt;

&lt;p&gt;Stripe's retry policy (from the official docs) is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live mode:&lt;/strong&gt; retries for up to &lt;strong&gt;three days&lt;/strong&gt; with &lt;strong&gt;exponential backoff&lt;/strong&gt;.
Stripe does not publish the exact intervals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test/sandbox mode:&lt;/strong&gt; retries &lt;strong&gt;three times over a few hours&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;2xx&lt;/strong&gt; response stops retries; &lt;strong&gt;3xx/4xx/5xx or a timeout&lt;/strong&gt; schedules the
next attempt.&lt;/li&gt;
&lt;li&gt;After continuous failure, Stripe &lt;strong&gt;disables the endpoint&lt;/strong&gt; and emails you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode that bites people is the &lt;strong&gt;4xx trap&lt;/strong&gt;. Here's a minimal Express&lt;br&gt;
receiver that reproduces it — it returns &lt;code&gt;200&lt;/code&gt; for known events and &lt;code&gt;400&lt;/code&gt; for&lt;br&gt;
anything else, which looks "safe" until an event type you didn't list arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// receiver.js — the 4xx trap&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment_intent.succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Common mistake: reject anything "unknown" instead of acknowledging it&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;KNOWN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`rejecting &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; with 400`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ... handle the known event ...&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it, fire an &lt;code&gt;invoice.created&lt;/code&gt; event at it, and watch Stripe's &lt;em&gt;Event&lt;br&gt;
deliveries&lt;/em&gt; tab. You'll see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each retry that returns &lt;code&gt;400&lt;/code&gt; burns another chunk of the window. The event is&lt;br&gt;
not corrupt and your code is not throwing — it's just refusing to acknowledge,&lt;br&gt;
which Stripe correctly treats as a failure. And a &lt;code&gt;400&lt;/code&gt; looks &lt;em&gt;so&lt;/em&gt; close to&lt;br&gt;
"handled" that it never triggers an alert.&lt;/p&gt;

&lt;p&gt;The second failure mode is the simpler one: your server is &lt;strong&gt;down&lt;/strong&gt; through the&lt;br&gt;
whole window. No &lt;code&gt;4xx&lt;/code&gt;, no logs — the requests never land. Three days later the&lt;br&gt;
endpoint is disabled and the events are simply gone.&lt;/p&gt;

&lt;p&gt;Two things to know about recovery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual resend&lt;/strong&gt; exists, but it's bounded: Stripe Dashboard "Resend" works up
to &lt;strong&gt;15 days&lt;/strong&gt; after creation; &lt;code&gt;stripe events resend&lt;/code&gt; via CLI up to &lt;strong&gt;30
days&lt;/strong&gt;. Beyond that the event is unrecoverable.&lt;/li&gt;
&lt;li&gt;Stripe &lt;strong&gt;regenerates the signature and timestamp on every attempt&lt;/strong&gt;, so if
you're verifying signatures (you should be), don't assume the timestamp equals
event creation time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The operational lesson
&lt;/h2&gt;

&lt;p&gt;The core mistake is treating Stripe's retry window as your retry policy. It&lt;br&gt;
isn't. It's a &lt;em&gt;best-effort&lt;/em&gt; delivery loop with a hard ceiling, tuned for Stripe's&lt;br&gt;
queue health, not for your outage windows.&lt;/p&gt;

&lt;p&gt;What changed for us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge first, process second.&lt;/strong&gt; Return &lt;code&gt;2xx&lt;/code&gt; as soon as the payload is
durably accepted, before any business logic. If processing fails later, that's
&lt;em&gt;your&lt;/em&gt; problem to retry — but Stripe's window stops being the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the retry schedule.&lt;/strong&gt; 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
&lt;code&gt;1m → 5m → 15m → 1h → 6h → 24h&lt;/code&gt;, then parks it in a dead-letter queue with
one-click replay — so a 6-hour outage doesn't eat anyone's retry window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on delivery failure, not on your own 5xx.&lt;/strong&gt; If you only alert when
&lt;em&gt;your&lt;/em&gt; code throws, the &lt;code&gt;400&lt;/code&gt; trap and the "server down" case both stay silent
because neither produces a local stack trace.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where a queue is &lt;em&gt;not&lt;/em&gt; the right tool
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself here: if your only problem is the occasional missed event&lt;br&gt;
and you're fine doing a manual "Resend" from the Dashboard or a &lt;code&gt;stripe events&lt;br&gt;
list&lt;/code&gt; reconciliation sweep, you don't need a queue — a cron job that backfills&lt;br&gt;
from the Stripe API covers a lot. A queue earns its keep when you can't afford&lt;br&gt;
the 3-day ceiling: high-volume checkout flows, endpoints that are down for hours&lt;br&gt;
during deploys, or when you want signed, replayable payloads and per-event&lt;br&gt;
diagnostics instead of Stripe's dashboard alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If a three-day retry ceiling is a liability for you, point your Stripe webhook at&lt;br&gt;
a Dead Letter endpoint and keep your real URL secret — the 3-endpoint free tier&lt;br&gt;
is enough to test with. Setup guide: &lt;a href="https://app.deadletterhub.io/docs/guides/stripe" rel="noopener noreferrer"&gt;https://app.deadletterhub.io/docs/guides/stripe&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>stripe</category>
      <category>node</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
