<?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: CheckoutAlarm</title>
    <description>The latest articles on DEV Community by CheckoutAlarm (@checkoutalarm).</description>
    <link>https://dev.to/checkoutalarm</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%2F4197007%2Fcf18857a-38e9-4946-a18c-094d32c8a200.png</url>
      <title>DEV Community: CheckoutAlarm</title>
      <link>https://dev.to/checkoutalarm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/checkoutalarm"/>
    <language>en</language>
    <item>
      <title>Stripe webhook not firing: 7 causes and how to check</title>
      <dc:creator>CheckoutAlarm</dc:creator>
      <pubDate>Wed, 21 Oct 2026 23:32:23 +0000</pubDate>
      <link>https://dev.to/checkoutalarm/stripe-webhook-not-firing-7-causes-and-how-to-check-2nj6</link>
      <guid>https://dev.to/checkoutalarm/stripe-webhook-not-firing-7-causes-and-how-to-check-2nj6</guid>
      <description>&lt;p&gt;A payment went through, but your app never heard about it. Here is where to look, in order. Every fact links to Stripe's own docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with Stripe's delivery log
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Workbench → Webhooks&lt;/strong&gt;, pick your endpoint, and open the &lt;strong&gt;Event deliveries&lt;/strong&gt; tab. Each event shows as &lt;code&gt;Delivered&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;, with the HTTP status code of each try. (&lt;a href="https://docs.stripe.com/webhooks#debug" rel="noopener noreferrer"&gt;Stripe: Debug webhook integrations&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The event isn't listed at all.&lt;/strong&gt; The cause is in your Stripe setup: causes 1–3 and 6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The event is listed with an error.&lt;/strong&gt; The cause is your endpoint or your network: causes 4, 5 and 7.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. The endpoint is turned off (or was deleted)
&lt;/h2&gt;

&lt;p&gt;Every webhook endpoint has a &lt;code&gt;status&lt;/code&gt; of &lt;code&gt;enabled&lt;/code&gt; or &lt;code&gt;disabled&lt;/code&gt;. (&lt;a href="https://docs.stripe.com/api/webhook_endpoints/object" rel="noopener noreferrer"&gt;API: Webhook Endpoint object&lt;/a&gt;) Stripe won't resend events made while an endpoint was turned off. (&lt;a href="https://docs.stripe.com/workbench/event-destinations" rel="noopener noreferrer"&gt;Manage event destinations&lt;/a&gt;) If the endpoint is off or deleted when a retry is due, Stripe stops retrying that event. (&lt;a href="https://docs.stripe.com/webhooks#automatic-retries" rel="noopener noreferrer"&gt;Automatic retries&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the endpoint in Workbench → Webhooks and make sure it's enabled.&lt;/li&gt;
&lt;li&gt;Or, with the API, get the endpoint and read its &lt;code&gt;status&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. The event type isn't selected
&lt;/h2&gt;

&lt;p&gt;An endpoint only gets the event types you pick for it. Stripe suggests picking only the ones you need. (&lt;a href="https://docs.stripe.com/webhooks#best-practices" rel="noopener noreferrer"&gt;Best practices&lt;/a&gt;) So if your code waits for &lt;code&gt;checkout.session.completed&lt;/code&gt; but the endpoint only gets &lt;code&gt;payment_intent.succeeded&lt;/code&gt;, your code never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compare the endpoint's event list with the &lt;code&gt;event.type&lt;/code&gt; values your code handles.&lt;/li&gt;
&lt;li&gt;Edit the endpoint in Workbench, or update it with the API.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Sandbox (test) and live mode don't match
&lt;/h2&gt;

&lt;p&gt;Sandbox and live are separate, each with its own keys. Sandbox keys start with &lt;code&gt;sk_test_&lt;/code&gt;, &lt;code&gt;rk_test_&lt;/code&gt; or &lt;code&gt;pk_test_&lt;/code&gt;. Live keys start with &lt;code&gt;sk_live_&lt;/code&gt;, &lt;code&gt;rk_live_&lt;/code&gt; or &lt;code&gt;pk_live_&lt;/code&gt;. (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;API keys&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Webhook signing secrets are not API keys. If one endpoint serves both modes, its secret is different in each. (&lt;a href="https://docs.stripe.com/webhooks#endpoint-secrets" rel="noopener noreferrer"&gt;Endpoint secrets&lt;/a&gt;) For Connect, sandbox accounts need their own endpoints. (&lt;a href="https://docs.stripe.com/connect/webhooks" rel="noopener noreferrer"&gt;Connect webhooks&lt;/a&gt;) A common case: it all worked in testing, but nobody made a live endpoint.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stripe listen&lt;/code&gt; uses a sandbox by default. Live events need the &lt;code&gt;--live&lt;/code&gt; flag. (&lt;a href="https://docs.stripe.com/cli/listen" rel="noopener noreferrer"&gt;CLI: listen&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Switch the Dashboard to live mode and make sure a live endpoint exists for your real URL.&lt;/li&gt;
&lt;li&gt;Make sure your live app uses the live &lt;code&gt;whsec_&lt;/code&gt; secret.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. Signature checks fail, so your code rejects the event
&lt;/h2&gt;

&lt;p&gt;Stripe did send the event, but your code answered &lt;code&gt;400&lt;/code&gt;. To you, that looks like "not firing." The error &lt;code&gt;No signatures found matching the expected signature for payload&lt;/code&gt; means one of three inputs is wrong: the raw body, the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, or the endpoint secret. (&lt;a href="https://docs.stripe.com/events/manage-webhook-endpoints#signature-errors" rel="noopener noreferrer"&gt;Signature errors&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong secret.&lt;/strong&gt; The &lt;code&gt;stripe listen&lt;/code&gt; secret is not the Dashboard endpoint's secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The body changed first.&lt;/strong&gt; Stripe needs the exact raw body. Tools that parse JSON first break the check. With Express, put the webhook route before &lt;code&gt;app.use(express.json())&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clock drift.&lt;/strong&gt; Stripe's libraries allow 5 minutes by default, so keep your server clock synced with NTP. (&lt;a href="https://docs.stripe.com/webhooks#replay-attacks" rel="noopener noreferrer"&gt;Preventing replay attacks&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compare the secret your code really loads with the one in Workbench (log only its last few characters).&lt;/li&gt;
&lt;li&gt;Log the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. It should look like &lt;code&gt;t=…,v1=…&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make sure you pass the unparsed body to the check.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. Your endpoint returns non-2xx, redirects or times out
&lt;/h2&gt;

&lt;p&gt;Stripe counts any non-2xx answer as a failed delivery, redirects included. (&lt;a href="https://docs.stripe.com/webhooks#fix-http-status-codes" rel="noopener noreferrer"&gt;Status codes&lt;/a&gt;)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3xx:&lt;/strong&gt; a failure. Register the final URL the redirect leads to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4xx:&lt;/strong&gt; blocked access (401, 403, 405) or the wrong path (404).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5xx:&lt;/strong&gt; your app hit an error. Check your logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS errors:&lt;/strong&gt; Stripe needs TLS 1.2 or higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts:&lt;/strong&gt; answer 2xx fast and do slow work later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retries: in live mode Stripe retries for up to three days. In a sandbox, three times over a few hours. You can resend by hand from the Dashboard for up to 15 days, or with &lt;code&gt;stripe events resend&lt;/code&gt; for up to 30 days. (&lt;a href="https://docs.stripe.com/webhooks#manual-retries" rel="noopener noreferrer"&gt;Manual retries&lt;/a&gt;) For subscriptions, Stripe also emails you when a webhook is failing. (&lt;a href="https://docs.stripe.com/billing/subscriptions/webhooks" rel="noopener noreferrer"&gt;Webhooks with subscriptions&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open a failed delivery in Event deliveries and read the status code.&lt;/li&gt;
&lt;li&gt;To find what you missed, call &lt;code&gt;GET /v1/events&lt;/code&gt; with &lt;code&gt;delivery_success=false&lt;/code&gt;. It covers the last 30 days. (&lt;a href="https://docs.stripe.com/events/manage-webhook-endpoints#process-undelivered-events" rel="noopener noreferrer"&gt;Process undelivered events&lt;/a&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  6. Connect: you listen to the wrong account
&lt;/h2&gt;

&lt;p&gt;On a Connect platform, each destination listens to one of two scopes: &lt;strong&gt;your account&lt;/strong&gt; (your platform's events) or &lt;strong&gt;connected accounts&lt;/strong&gt; (events on their resources, like direct charges). If you need both, make both. (&lt;a href="https://docs.stripe.com/webhooks#webhook-connect" rel="noopener noreferrer"&gt;Event destinations for Connect&lt;/a&gt;) Events from connected accounts have a top-level &lt;code&gt;account&lt;/code&gt; field. (&lt;a href="https://docs.stripe.com/connect/webhooks" rel="noopener noreferrer"&gt;Connect webhooks&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check which scope your destination uses.&lt;/li&gt;
&lt;li&gt;Locally, test with &lt;code&gt;stripe listen --forward-connect-to localhost:PORT/webhook&lt;/code&gt; for connected accounts, and &lt;code&gt;--forward-to&lt;/code&gt; for your own.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  7. A firewall, login wall or CSRF check blocks Stripe
&lt;/h2&gt;

&lt;p&gt;Your endpoint must be a public HTTPS URL that takes &lt;code&gt;POST&lt;/code&gt;. (&lt;a href="https://docs.stripe.com/webhooks#register-webhook" rel="noopener noreferrer"&gt;Set up your endpoint&lt;/a&gt;) Common blockers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A login in front of the route.&lt;/strong&gt; Login walls, preview-deploy protection or JWT checks answer 401 or 403.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSRF checks.&lt;/strong&gt; Rails, Django and others may reject the POST. Stripe says to exempt the webhook route. (&lt;a href="https://docs.stripe.com/webhooks#csrf-protection" rel="noopener noreferrer"&gt;CSRF&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An old IP allowlist.&lt;/strong&gt; Stripe lists its webhook IPs at stripe.com/files/ips/ips_webhooks.txt. (&lt;a href="https://docs.stripe.com/ips" rel="noopener noreferrer"&gt;Domains and IP addresses&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send a plain &lt;code&gt;POST&lt;/code&gt; to your webhook URL from outside your network. You should get your code's answer, not a login page or a redirect.&lt;/li&gt;
&lt;li&gt;If you filter by IP, compare your list with Stripe's current list.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Also check: API version
&lt;/h2&gt;

&lt;p&gt;An event's shape depends on the API version set when it happened. Changing the version later doesn't change old events. (&lt;a href="https://docs.stripe.com/webhooks#api-versioning" rel="noopener noreferrer"&gt;API versioning&lt;/a&gt;) So delivery can work while your code can't read the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 5-minute test with the Stripe CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe login
stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:4242/webhook   &lt;span class="c"&gt;# prints a whsec_ secret for local use&lt;/span&gt;
&lt;span class="c"&gt;# in another terminal:&lt;/span&gt;
stripe trigger payment_intent.succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the event reaches your code this way but never in production, your code is fine. The problem is the live endpoint: causes 1–3, 5 and 7. &lt;code&gt;stripe listen --load-from-webhooks-api&lt;/code&gt; listens with the endpoints you already set up. (&lt;a href="https://docs.stripe.com/webhooks#local-listener" rel="noopener noreferrer"&gt;Test locally&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my Stripe webhook work in test mode but not live?&lt;/strong&gt;&lt;br&gt;
Sandbox and live use separate keys, and an endpoint's signing secret is different in each mode. Make sure a live-mode endpoint exists for your production URL and that production uses the live &lt;code&gt;whsec_&lt;/code&gt; secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does Stripe retry a failed webhook?&lt;/strong&gt;&lt;br&gt;
In live mode, for up to three days with exponential backoff. In a sandbox, three times over a few hours. You can resend by hand from the Dashboard for up to 15 days, or with the Stripe CLI for up to 30 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Stripe treat a redirect as a successful webhook delivery?&lt;/strong&gt;&lt;br&gt;
No. Stripe treats 3xx responses as failures. Register the final URL the redirect leads to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do I get "No signatures found matching the expected signature for payload"?&lt;/strong&gt;&lt;br&gt;
One of three inputs is wrong: the endpoint secret (CLI and Dashboard secrets differ), the raw request body (it must not be parsed or changed), or the &lt;code&gt;Stripe-Signature&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test a Stripe webhook locally?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;stripe listen --forward-to localhost:4242/webhook&lt;/code&gt;. Then, in another terminal, run &lt;code&gt;stripe trigger payment_intent.succeeded&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stripe docs checked on Oct 17, 2026.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;From the CheckoutAlarm team.&lt;/em&gt; If you have a failed delivery in front of you, our free &lt;a href="https://checkoutalarm.com/tools/stripe-webhook-error-decoder" rel="noopener noreferrer"&gt;Stripe webhook error decoder&lt;/a&gt; explains the status and the fix. CheckoutAlarm itself emails you when Stripe events stay undelivered or an endpoint gets turned off (read-only Stripe key, after you prove you own the site). There's a &lt;a href="https://checkoutalarm.com/start" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; for one site. It doesn't fix webhooks for you; Stripe's Event deliveries tab is still the place to look for the details of each delivery.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://checkoutalarm.com/guides/stripe-webhook-not-firing" rel="noopener noreferrer"&gt;checkoutalarm.com/guides/stripe-webhook-not-firing&lt;/a&gt;, where it's kept up to date.&lt;/em&gt;&lt;/p&gt;

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