<?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: WebhookProof</title>
    <description>The latest articles on DEV Community by WebhookProof (@webhookproof).</description>
    <link>https://dev.to/webhookproof</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%2F4192985%2Fff72d678-79cb-4abc-994d-99f070f94e30.png</url>
      <title>DEV Community: WebhookProof</title>
      <link>https://dev.to/webhookproof</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webhookproof"/>
    <language>en</language>
    <item>
      <title>Stripe webhook timestamp outside the tolerance zone? Fix the clock, don't set tolerance to 0</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:03:34 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-webhook-timestamp-outside-the-tolerance-zone-fix-the-clock-dont-set-tolerance-to-0-5e0k</link>
      <guid>https://dev.to/webhookproof/stripe-webhook-timestamp-outside-the-tolerance-zone-fix-the-clock-dont-set-tolerance-to-0-5e0k</guid>
      <description>&lt;p&gt;A webhook handler can pass every signature check and still reject the event because it looks too old. The fix people reach for first is setting the tolerance to 0, which makes the error disappear. Stripe's docs name that as a mistake.&lt;/p&gt;

&lt;p&gt;This post covers what the timestamp in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header is for, what Stripe says about the default tolerance, what it recommends for server clocks, and why retries aren't the cause. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the timestamp is for
&lt;/h2&gt;

&lt;p&gt;Stripe's docs describe the threat: "A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them." To mitigate it, "Stripe includes a timestamp in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header."&lt;/p&gt;

&lt;p&gt;That timestamp can't be edited on the way: "Because this timestamp is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature."&lt;/p&gt;

&lt;p&gt;So the signature proves the payload is Stripe's, and the timestamp lets the application decide whether it arrived recently enough. Per the docs: "If the signature is valid but the timestamp is too old, you can have your application reject the payload."&lt;/p&gt;

&lt;h2&gt;
  
  
  The default tolerance
&lt;/h2&gt;

&lt;p&gt;Stripe states: "Our libraries have a default tolerance of 5 minutes between the timestamp and the current time." The docs add: "You can change this tolerance by providing an additional parameter when verifying signatures."&lt;/p&gt;

&lt;p&gt;An error about the timestamp being outside the tolerance therefore means the gap between the signed timestamp and the current time, as the verifying code sees it, was larger than the allowed window. Two places can produce that gap: the signed timestamp is old, or the server's idea of "now" is off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the clock
&lt;/h2&gt;

&lt;p&gt;Stripe's instruction: "Use Network Time Protocol (NTP) to make sure that your server's clock is accurate and synchronizes with the time on Stripe's servers."&lt;/p&gt;

&lt;p&gt;Practical checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the host or VM that runs the webhook handler has time synchronization turned on.&lt;/li&gt;
&lt;li&gt;Log the difference between the current time and the &lt;code&gt;t=&lt;/code&gt; value of each incoming &lt;code&gt;Stripe-Signature&lt;/code&gt; header. A consistent offset points to clock drift.&lt;/li&gt;
&lt;li&gt;Check where verification happens. If the raw body and header are stored and verified later, the age is measured at that later moment. Verify in the request handler, then hand work to a queue. Stripe asks the endpoint to "quickly return a successful status code (&lt;code&gt;2xx&lt;/code&gt;) before any complex logic that could cause a timeout."
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Verify on receipt, using the default tolerance. Don't pass 0.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'stripe-signature'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...return 2xx now, do the slow work in a worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Retries are not the cause
&lt;/h2&gt;

&lt;p&gt;After downtime it's tempting to blame old retried events. Stripe's docs say otherwise: "Stripe generates the timestamp and signature each time we send an event to your endpoint. If Stripe retries an event (for example, your endpoint previously replied with a non-&lt;code&gt;2xx&lt;/code&gt; status code), then we generate a new signature and timestamp for the new delivery attempt."&lt;/p&gt;

&lt;p&gt;A retried delivery carries a fresh timestamp. If retries fail with a timestamp error, the clock or a delayed verification step is the place to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tolerance of 0 is a trap
&lt;/h2&gt;

&lt;p&gt;Stripe lists it as a common mistake: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely."&lt;/p&gt;

&lt;p&gt;With the recency check off, a captured payload and signature can be re-sent later and still pass. That's the replay attack the timestamp exists to stop. A very large tolerance gives much of the same exposure, so the clock is the thing to correct.&lt;/p&gt;

&lt;p&gt;A related habit: replaying a saved payload by hand. A body and signature header copied from a log will fail once they're past the tolerance window. That's the protection working, not a bug. For testing, trigger a fresh event so Stripe signs a new timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Signature verification runs in the request handler, on the raw body&lt;/li&gt;
&lt;li&gt;[ ] The default tolerance is used (no &lt;code&gt;0&lt;/code&gt;, no huge value)&lt;/li&gt;
&lt;li&gt;[ ] The server clock is synchronized with NTP&lt;/li&gt;
&lt;li&gt;[ ] The offset between current time and &lt;code&gt;t=&lt;/code&gt; is logged while debugging&lt;/li&gt;
&lt;li&gt;[ ] The handler returns a &lt;code&gt;2xx&lt;/code&gt; before slow work&lt;/li&gt;
&lt;li&gt;[ ] Hand-replayed payloads are expected to fail once they're older than the window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rejected timestamp is one of several ways a webhook endpoint can misbehave without anyone noticing. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide is here: &lt;a href="https://webhookproof.com/guides/stripe-webhook-timestamp-outside-tolerance" rel="noopener noreferrer"&gt;Stripe webhook timestamp outside the tolerance zone&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-13.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (Preventing replay attacks, default tolerance, NTP, tolerance 0, retries and new timestamps, quickly return a 2xx response): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>Stripe webhook timed out? Return a 2xx first, then do the heavy work in a queue</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Wed, 11 Nov 2026 15:02:33 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-webhook-timed-out-return-a-2xx-first-then-do-the-heavy-work-in-a-queue-3ifc</link>
      <guid>https://dev.to/webhookproof/stripe-webhook-timed-out-return-a-2xx-first-then-do-the-heavy-work-in-a-queue-3ifc</guid>
      <description>&lt;p&gt;A webhook handler often does everything inside the request: verify the event, update the database, send the email, call another API, and only then answer. On a quiet day that finishes in time. On a busy one, or when a downstream service is slow, the response arrives late and Stripe's delivery log shows a timeout.&lt;/p&gt;

&lt;p&gt;This post covers what that status means, what Stripe says the handler should do first, how to move the slow part into a queue, and how to catch up on events that failed in the meantime. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell if the endpoint is timing out
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the webhook endpoint in the Stripe Dashboard and go to the Event deliveries tab. Stripe's docs describe it as a list of events and whether they're &lt;code&gt;Delivered&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt;, or &lt;code&gt;Failed&lt;/code&gt;, and clicking an event shows "the HTTP status code of the delivery attempt and the time of pending future deliveries."&lt;/li&gt;
&lt;li&gt;Look for the &lt;code&gt;(Timed out) ERR&lt;/code&gt; status. Stripe's description: "The destination server took too long to respond to the webhook request."&lt;/li&gt;
&lt;li&gt;Compare the timed-out events with what the handler does before it responds. Anything slow that runs ahead of the response is a suspect.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Stripe asks the handler to do
&lt;/h2&gt;

&lt;p&gt;Stripe's fix for the timed-out status is: "Make sure you defer complex logic and return a successful response immediately in your webhook handling code."&lt;/p&gt;

&lt;p&gt;The handler guidance says the same thing in more detail. The endpoint should be set up so that it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Verifies the webhook request is generated by Stripe using the JSON payload, the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, and the &lt;code&gt;whsec_&lt;/code&gt; webhook signing secret."&lt;/li&gt;
&lt;li&gt;"Quickly returns a successful status code (&lt;code&gt;2xx&lt;/code&gt;) before any complex logic that might cause a timeout."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe gives an example: "you must return a &lt;code&gt;200&lt;/code&gt; response before updating a customer's invoice as paid in your accounting system."&lt;/p&gt;

&lt;p&gt;So the order is: verify the signature, return 2xx, then do the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a slow response is worse than it looks
&lt;/h2&gt;

&lt;p&gt;A timeout isn't a quiet event. Stripe counts it as a failed delivery and retries: "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode." Sandbox events are retried "three times over the course of a few hours."&lt;/p&gt;

&lt;p&gt;Each retry is a second chance for the same work to run, so the handler can also see duplicates. Stripe notes that "Webhook endpoints might occasionally receive the same event more than once" and recommends "logging the event IDs you've processed, and then not processing already-logged events."&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand the work to a queue
&lt;/h2&gt;

&lt;p&gt;Stripe recommends asynchronous processing directly: "Configure your handler to process incoming events with an asynchronous queue." Its reasoning: "You might encounter scalability issues if you choose to process events synchronously. Any large spike in webhook deliveries (for example, during the beginning of the month when all subscriptions renew) might overwhelm your endpoint hosts." And: "Asynchronous queues allow you to process the concurrent events at a rate your system can support."&lt;/p&gt;

&lt;p&gt;The handler's only jobs are to verify, store or enqueue, and answer.&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;// Verified webhook handler: verify, enqueue, answer. Nothing slow before the response.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&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;// raw body, unmodified, for signature verification&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stripe-signature'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bad signature'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&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="p"&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;// Record the event ID; skip it if it's already been seen.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;isNew&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The slow work (database writes, emails, third-party calls) runs in a worker.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ok'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Worker, outside the request. Safe to run twice for the same event ID.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markProcessing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// grant access, send the email, update the accounting system...&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details from Stripe's docs matter here. First, the raw body: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body." Second, Stripe doesn't promise order: "Stripe doesn't guarantee the delivery of events in the order that they're generated," so the worker shouldn't depend on one event arriving before another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the failures that already happened
&lt;/h2&gt;

&lt;p&gt;Stripe retries on its own, but a slow backlog can be cleared faster. For events that failed during an outage or slowdown, Stripe's docs say to call the List Events API with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ending_before&lt;/code&gt;: "An event ID that was successfully delivered immediately before the outage."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;types&lt;/code&gt;: "The event types your endpoint listens for."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delivery_success&lt;/code&gt;: "Set to &lt;code&gt;false&lt;/code&gt; to return only events that failed delivery to at least one of your endpoints."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Stripe only returns events created in the last 30 days." The response "returns up to 10 events" by default, and &lt;code&gt;ending_before&lt;/code&gt; with auto-pagination "returns events in chronological order."&lt;/p&gt;

&lt;p&gt;Manual processing has one catch: "Stripe still considers manually processed events as undelivered and continues its automatic retry schedule. When your endpoint receives an event you've already processed, skip it and return a &lt;code&gt;200&lt;/code&gt; response to stop further retries." That's why the worker above checks the event ID before doing anything.&lt;/p&gt;

&lt;p&gt;For single events, "In the Stripe Dashboard, click Resend when looking at a specific event. This works for up to 15 days after the event creation." The Stripe CLI works "for up to 30 days." Stripe adds that "Manually resending an event that failed to reach a webhook endpoint doesn't cancel Stripe's automatic retry behavior, even if it results in a &lt;code&gt;2xx&lt;/code&gt; status code."&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that keep the timeout coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Work before the response.&lt;/strong&gt; Any database write, email, or third-party API call ahead of &lt;code&gt;return 200&lt;/code&gt; can stretch the response past the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification that isn't the problem.&lt;/strong&gt; Signature checks belong before the 2xx, as Stripe lists, so keep them. Move everything after them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No queue under load.&lt;/strong&gt; A burst, such as renewals at the start of the month, can overwhelm a handler that processes events inline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong status on skipped events.&lt;/strong&gt; If a duplicate is skipped but the handler doesn't answer 200, Stripe keeps retrying.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Event deliveries shows no &lt;code&gt;(Timed out)&lt;/code&gt; entries, or each one has been traced to slow work before the response&lt;/li&gt;
&lt;li&gt;[ ] The handler verifies the signature on the raw body, then returns a &lt;code&gt;2xx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Nothing slow (database, email, third-party APIs) runs before the response&lt;/li&gt;
&lt;li&gt;[ ] Events go to an asynchronous queue and a worker processes them&lt;/li&gt;
&lt;li&gt;[ ] The worker logs event IDs and skips events it has already processed&lt;/li&gt;
&lt;li&gt;[ ] The handler doesn't depend on events arriving in a particular order&lt;/li&gt;
&lt;li&gt;[ ] Failed events from the last 30 days have been listed with &lt;code&gt;delivery_success=false&lt;/code&gt; and processed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A timeout is one of several ways an endpoint can fail without anyone noticing. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide is here: &lt;a href="https://webhookproof.com/guides/stripe-webhook-timed-out" rel="noopener noreferrer"&gt;Stripe webhook timed out?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-09.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (handler steps, &lt;code&gt;(Timed out)&lt;/code&gt; status and fix, event deliveries, automatic and manual retries, event ordering, duplicate events, handle events asynchronously, quickly return a 2xx response): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Manage webhook endpoints (process undelivered events, List Events parameters, 30-day window, respond to automatic retries, async queue, quickly return a 2xx response): &lt;a href="https://docs.stripe.com/events/manage-webhook-endpoints" rel="noopener noreferrer"&gt;https://docs.stripe.com/events/manage-webhook-endpoints&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>backend</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Refunded or disputed, but the customer still has access? Your Stripe webhook only listens for the purchase</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:13:47 +0000</pubDate>
      <link>https://dev.to/webhookproof/refunded-or-disputed-but-the-customer-still-has-access-your-stripe-webhook-only-listens-for-the-5hfj</link>
      <guid>https://dev.to/webhookproof/refunded-or-disputed-but-the-customer-still-has-access-your-stripe-webhook-only-listens-for-the-5hfj</guid>
      <description>&lt;p&gt;A lot of paywalls handle exactly one Stripe event. &lt;code&gt;checkout.session.completed&lt;/code&gt; arrives, the handler sets &lt;code&gt;paid = true&lt;/code&gt;, and that's the end of it. Nothing in the code ever sets it back.&lt;/p&gt;

&lt;p&gt;So a refund goes out, or a customer's bank opens a dispute, or a subscription ends, and the database still says paid. The app keeps the door open for someone who's no longer paying. This post covers the events that should take access away, how to tie them back to the right user, and the edge cases worth deciding on up front. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell if access is one-way
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the webhook endpoint in the Stripe Dashboard and look at the events it's subscribed to. If the list is only checkout or payment-success events, Stripe never tells the app about refunds or disputes.&lt;/li&gt;
&lt;li&gt;Refund a test-mode purchase, then reload the app as that test user. If the paid content is still there, access only ever goes one direction.&lt;/li&gt;
&lt;li&gt;Search the handler for &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt; and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;. If none of them appear, nothing revokes access.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The events that should take access away
&lt;/h2&gt;

&lt;p&gt;From Stripe's list of event types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;charge.refunded&lt;/code&gt;: "Occurs whenever a charge is refunded, including partial refunds."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt;: "Occurs whenever a customer disputes a charge with their bank."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.closed&lt;/code&gt;: "Occurs when a dispute is closed and the dispute status changes to &lt;code&gt;lost&lt;/code&gt;, &lt;code&gt;warning_closed&lt;/code&gt;, or &lt;code&gt;won&lt;/code&gt;." This is the one to use for giving access back after a dispute is won.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refund.failed&lt;/code&gt;: "Occurs whenever a refund has failed." Stripe's refund docs say that in that case "you need to arrange an alternative way to provide your customer with a refund."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For subscriptions, Stripe's cancellation docs describe &lt;code&gt;customer.subscription.deleted&lt;/code&gt; as "Sent when a subscription is canceled. The cancellation can result from a direct call to delete the subscription or when a subscription with &lt;code&gt;cancel_at_period_end: true&lt;/code&gt; reaches the end of its billing period."&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;cancel_at_period_end&lt;/code&gt; doesn't end anything yet. The same docs say &lt;code&gt;customer.subscription.updated&lt;/code&gt; is "Sent for any subscription update, including when &lt;code&gt;cancel_at_period_end&lt;/code&gt; is set to &lt;code&gt;true&lt;/code&gt;." The customer has paid through the end of the period, so keep access until the deleted event arrives.&lt;/p&gt;

&lt;p&gt;Each of these has to be added to the endpoint's event list, in the Dashboard or through &lt;code&gt;enabled_events&lt;/code&gt; on the endpoint. Stripe only sends the events an endpoint is subscribed to. Stripe's refund docs also recommend that you "verify webhook signatures" for these events, which matters because the same handler that revokes access also grants it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map the event back to the user
&lt;/h2&gt;

&lt;p&gt;Refund and dispute events carry a Charge or a Dispute, not the Checkout Session that granted access. Both point back to the payment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the Charge object, &lt;code&gt;payment_intent&lt;/code&gt; is the "ID of the PaymentIntent associated with this charge."&lt;/li&gt;
&lt;li&gt;On the Dispute object, &lt;code&gt;payment_intent&lt;/code&gt; is the "ID of the PaymentIntent that’s disputed," and &lt;code&gt;charge&lt;/code&gt; is the "ID of the charge that’s disputed."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Checkout Session object has a &lt;code&gt;payment_intent&lt;/code&gt; field too. So at fulfillment time, store the session's &lt;code&gt;payment_intent&lt;/code&gt; next to the user. Revoking then becomes a single lookup.&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;// Inside a verified webhook handler (signature already checked on the raw body)&lt;/span&gt;
&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;grantAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_reference_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.refunded&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// refunded is true only for a full refund; decide what a partial refund means&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.dispute.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disputed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.dispute.closed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;won&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;restoreByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeBySubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Decide the edge cases before they happen
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial refunds.&lt;/strong&gt; &lt;code&gt;charge.refunded&lt;/code&gt; fires for those too. On the Charge, &lt;code&gt;refunded&lt;/code&gt; means "Whether the charge has been fully refunded. If the charge is only partially refunded, this attribute will still be false," and &lt;code&gt;amount_refunded&lt;/code&gt; holds the refunded amount. Pick a rule, such as revoking only on a full refund, and write it down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inquiries.&lt;/strong&gt; Stripe's dispute docs explain that "Some card networks initiate a preliminary phase before creating a formal dispute," which Stripe calls an inquiry, and that in the API an inquiry's status "is prepended with &lt;code&gt;warning&lt;/code&gt;," as in &lt;code&gt;warning_needs_response&lt;/code&gt;. The same page notes the bank often asks "because the cardholder doesn’t recognize the transaction description." To avoid cutting off a customer over a confusing statement line, revoke on formal dispute statuses and leave &lt;code&gt;warning_&lt;/code&gt; statuses alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes that are won.&lt;/strong&gt; When &lt;code&gt;charge.dispute.closed&lt;/code&gt; arrives with status &lt;code&gt;won&lt;/code&gt;, restore access, as in the &lt;code&gt;switch&lt;/code&gt; above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that make revocation fail quietly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access granted by the success page.&lt;/strong&gt; If &lt;code&gt;/success&lt;/code&gt; unlocks access on its own, a webhook can't keep it revoked. Grant from the webhook and have the page only read the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate deliveries.&lt;/strong&gt; The same event can arrive more than once, so revoke and restore should be safe to run twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An endpoint Stripe can't reach.&lt;/strong&gt; Then neither grants nor revokes arrive at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up the customers who already kept access
&lt;/h2&gt;

&lt;p&gt;Stripe's refund docs point to a Dashboard view of refunded payments. To catch disputes as well, page through charges with the API and check &lt;code&gt;refunded&lt;/code&gt; and &lt;code&gt;disputed&lt;/code&gt; ("Whether the charge has been disputed") on each one. Revoke each match with the same function the webhook now calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The endpoint subscribes to &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt; and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fulfillment stores the Checkout Session's &lt;code&gt;payment_intent&lt;/code&gt; with the user&lt;/li&gt;
&lt;li&gt;[ ] Revoke runs only for a full refund, or a written rule covers partial ones&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;warning_&lt;/code&gt; inquiry statuses are handled on purpose&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;cancel_at_period_end&lt;/code&gt; keeps access until &lt;code&gt;customer.subscription.deleted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A won dispute restores access&lt;/li&gt;
&lt;li&gt;[ ] Past refunds and disputes have been cleaned up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this helps if Stripe can't reach the endpoint in the first place. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide, with the revoke logic and a cleanup approach, is here: &lt;a href="https://webhookproof.com/guides/stripe-refund-or-dispute-does-not-revoke-access" rel="noopener noreferrer"&gt;Refunded or disputed Stripe payment still has access?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-04.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe API, Types of events (&lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt;, &lt;code&gt;refund.failed&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/events/types" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/events/types&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Refund and cancel payments (alternative refund when &lt;code&gt;refund.failed&lt;/code&gt;, verify webhook signatures, Dashboard view of refunded payments): &lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;https://docs.stripe.com/refunds&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Cancel subscriptions (&lt;code&gt;customer.subscription.deleted&lt;/code&gt;, &lt;code&gt;customer.subscription.updated&lt;/code&gt; when &lt;code&gt;cancel_at_period_end&lt;/code&gt; is set): &lt;a href="https://docs.stripe.com/billing/subscriptions/cancel" rel="noopener noreferrer"&gt;https://docs.stripe.com/billing/subscriptions/cancel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Charge object (&lt;code&gt;payment_intent&lt;/code&gt;, &lt;code&gt;refunded&lt;/code&gt;, &lt;code&gt;amount_refunded&lt;/code&gt;, &lt;code&gt;disputed&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/charges/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/charges/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Dispute object (&lt;code&gt;payment_intent&lt;/code&gt;, &lt;code&gt;charge&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/disputes/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/disputes/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Checkout Session object (&lt;code&gt;payment_intent&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/checkout/sessions/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/checkout/sessions/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, How disputes work (inquiries, &lt;code&gt;warning_&lt;/code&gt; statuses): &lt;a href="https://docs.stripe.com/disputes/how-disputes-work" rel="noopener noreferrer"&gt;https://docs.stripe.com/disputes/how-disputes-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>backend</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Customers paid while your Stripe webhook was down? How to find and recover every missed order</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:04:14 +0000</pubDate>
      <link>https://dev.to/webhookproof/customers-paid-while-your-stripe-webhook-was-down-how-to-find-and-recover-every-missed-order-4d5o</link>
      <guid>https://dev.to/webhookproof/customers-paid-while-your-stripe-webhook-was-down-how-to-find-and-recover-every-missed-order-4d5o</guid>
      <description>&lt;p&gt;A webhook endpoint breaks: a bad deploy, an expired secret, a new redirect, a login wall in front of the route. Checkout keeps working, so customers keep paying. Your app just never hears about it.&lt;/p&gt;

&lt;p&gt;Once the endpoint is fixed, the next question is the one that matters for revenue: who paid in the meantime, and how do they get what they bought? This post walks through Stripe's documented recovery path, step by step. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  First, how much time Stripe gives you
&lt;/h2&gt;

&lt;p&gt;Stripe doesn't drop a failed event right away. From the webhook docs: "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode." In a sandbox it's shorter: "We retry event deliveries created in a sandbox three times over the course of a few hours."&lt;/p&gt;

&lt;p&gt;There's one way to lose those retries. Stripe: "If your destination has been disabled or deleted when we attempt a retry, we prevent future retries of that event." And from the event destinations docs: "Stripe won't attempt to resend any events generated while the destination is disabled."&lt;/p&gt;

&lt;p&gt;So when an endpoint is failing, fix the URL or the code behind it. Don't delete or disable the endpoint to make the errors stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Confirm the live endpoint is enabled
&lt;/h2&gt;

&lt;p&gt;List your webhook endpoints with your &lt;strong&gt;live&lt;/strong&gt; secret key. Each endpoint has a &lt;code&gt;status&lt;/code&gt;, and Stripe's API reference says it "can be &lt;code&gt;enabled&lt;/code&gt; or &lt;code&gt;disabled&lt;/code&gt;."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; https://api.stripe.com/v1/webhook_endpoints &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_LIVE_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt;
&lt;span class="c"&gt;# Check each endpoint's "url", "status" and "enabled_events"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the URL is your production URL, the status is &lt;code&gt;enabled&lt;/code&gt;, and the event types you fulfill on are in &lt;code&gt;enabled_events&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: List exactly what failed
&lt;/h2&gt;

&lt;p&gt;Stripe's guide to processing undelivered events says to call the List Events API with three parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ending_before&lt;/code&gt;: "An event ID that was successfully delivered immediately before the outage. This anchors the list to the window of events you missed."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;types&lt;/code&gt;: "The event types your endpoint listens for."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delivery_success&lt;/code&gt;: "Set to &lt;code&gt;false&lt;/code&gt; to return only events that failed delivery to at least one of your endpoints."
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; https://api.stripe.com/v1/events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_LIVE_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;ending_before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;evt_LAST_GOOD_EVENT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"types[]=checkout.session.completed"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;delivery_success&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two limits to know. "Stripe only returns events created in the last 30 days." And "By default, the response returns up to 10 events. To retrieve all events, use auto-pagination."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Replay them without doing anything twice
&lt;/h2&gt;

&lt;p&gt;This is where recovery scripts cause new problems. Stripe warns: "Guard against processing the same event twice. Your manual recovery script might run more than once, and Stripe's automatic retries might deliver some events while your script is running."&lt;/p&gt;

&lt;p&gt;Stripe's pattern is to check whether an event is already handled, mark it as processing, do the work, then mark it as processed. Here is that shape in Node, using the library's &lt;code&gt;for await&lt;/code&gt; auto-pagination:&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;// recover-missed-events.mjs (Node 18+, stripe npm package)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ending_before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;evt_LAST_GOOD_EVENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;delivery_success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Your own data store: skip anything already being handled or done&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isProcessingOrProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markAsProcessing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fulfillCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markAsProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per Stripe's docs, "Using &lt;code&gt;ending_before&lt;/code&gt; with auto-pagination returns events in chronological order, so you process them in the order they were created."&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;fulfillCheckout&lt;/code&gt;, follow Stripe's fulfillment guide: "Retrieve the Checkout Session from the API with the &lt;code&gt;line_items&lt;/code&gt; property expanded" and "Check the &lt;code&gt;payment_status&lt;/code&gt; property to determine if it requires fulfillment." That way the replay trusts Stripe's current answer, not an old payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Make the endpoint answer the retries
&lt;/h2&gt;

&lt;p&gt;Processing the backlog yourself doesn't tell Stripe to stop. From the same guide: "Stripe still considers manually processed events as undelivered and continues its automatic retry schedule. When your endpoint receives an event you've already processed, skip it and return a &lt;code&gt;200&lt;/code&gt; response to stop further retries."&lt;/p&gt;

&lt;p&gt;So the webhook handler needs the same "already processed?" check as the script. If it sees an event ID it already handled, it returns &lt;code&gt;200&lt;/code&gt; and does nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only a few events? Resend them instead
&lt;/h2&gt;

&lt;p&gt;For a short outage, Stripe's two manual retry options may be enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"In the Stripe Dashboard, click &lt;strong&gt;Resend&lt;/strong&gt; when looking at a specific event. This works for up to 15 days after the event creation."&lt;/li&gt;
&lt;li&gt;"With the Stripe CLI, run the &lt;code&gt;stripe events resend &amp;lt;event_id&amp;gt; --webhook-endpoint=&amp;lt;endpoint_id&amp;gt;&lt;/code&gt; command. This works for up to 30 days after the event creation."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same caveat applies: "Manually resending an event that failed to reach a webhook endpoint doesn't cancel Stripe's automatic retry behavior, even if it results in a &lt;code&gt;2xx&lt;/code&gt; status code." Your handler still has to be safe to run twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Older than 30 days?
&lt;/h2&gt;

&lt;p&gt;Those events won't come back from the Events API. Rebuild from the payments themselves: the List Checkout Sessions API takes a &lt;code&gt;created&lt;/code&gt; filter ("Only return Checkout Sessions that were created during the given date interval") and a &lt;code&gt;status&lt;/code&gt; filter, where &lt;code&gt;complete&lt;/code&gt; means "The checkout session is complete." Run each session through the same &lt;code&gt;fulfillCheckout&lt;/code&gt; function, which checks &lt;code&gt;payment_status&lt;/code&gt; and skips anything already granted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next time, find out the same day
&lt;/h2&gt;

&lt;p&gt;Stripe's health alerts won't catch this on most accounts. The basic tier, which "All Stripe users receive," covers &lt;code&gt;402&lt;/code&gt; and &lt;code&gt;500&lt;/code&gt; error alerts. Webhook latency alerts are in the advanced tier, which is for "Users on Stripe's Growth, Premium or Enterprise Support plan." A small daily job that runs the &lt;code&gt;delivery_success=false&lt;/code&gt; query from Step 2 over the last 24 hours works on any account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The live endpoint is &lt;code&gt;enabled&lt;/code&gt; and points at the production URL&lt;/li&gt;
&lt;li&gt;[ ] Failing endpoints get fixed, not disabled or deleted, so Stripe keeps retrying&lt;/li&gt;
&lt;li&gt;[ ] Missed events are listed with &lt;code&gt;ending_before&lt;/code&gt;, &lt;code&gt;types&lt;/code&gt; and &lt;code&gt;delivery_success=false&lt;/code&gt;, with auto-pagination&lt;/li&gt;
&lt;li&gt;[ ] The recovery script and the webhook handler share one "already processed" check&lt;/li&gt;
&lt;li&gt;[ ] Already-processed events get a &lt;code&gt;200&lt;/code&gt; so Stripe stops retrying&lt;/li&gt;
&lt;li&gt;[ ] Fulfillment retrieves the Checkout Session and checks &lt;code&gt;payment_status&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Anything older than 30 days is rebuilt from Checkout Sessions&lt;/li&gt;
&lt;li&gt;[ ] A daily check flags failed deliveries before customers write in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before replaying a backlog, it helps to know the endpoint really is fixed. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide, including a daily alert script, is here: &lt;a href="https://webhookproof.com/guides/stripe-webhook-failing-silently" rel="noopener noreferrer"&gt;Is your Stripe webhook failing silently?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-30.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (automatic retries for up to three days in live mode, three times in a sandbox, no retries if disabled or deleted, Dashboard resend 15 days, CLI resend 30 days, resend doesn't cancel automatic retries): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Manage event destinations (no resend of events generated while a destination is disabled): &lt;a href="https://docs.stripe.com/workbench/event-destinations" rel="noopener noreferrer"&gt;https://docs.stripe.com/workbench/event-destinations&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Webhook Endpoint object (&lt;code&gt;status&lt;/code&gt; can be &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;https://docs.stripe.com/api/webhook_endpoints/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Process undelivered webhook events (&lt;code&gt;ending_before&lt;/code&gt;, &lt;code&gt;types&lt;/code&gt;, &lt;code&gt;delivery_success=false&lt;/code&gt;, last 30 days, 10 per page, chronological order, guard against double processing, manually processed events still retried, return &lt;code&gt;200&lt;/code&gt;): &lt;a href="https://docs.stripe.com/webhooks/process-undelivered-events" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks/process-undelivered-events&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, Auto-pagination (&lt;code&gt;for await&lt;/code&gt; in Node): &lt;a href="https://docs.stripe.com/api/pagination/auto" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/pagination/auto&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Fulfill orders, full-page Checkout (retrieve the Checkout Session, check &lt;code&gt;payment_status&lt;/code&gt;): &lt;a href="https://docs.stripe.com/checkout/fulfillment?ui=full-page" rel="noopener noreferrer"&gt;https://docs.stripe.com/checkout/fulfillment?ui=full-page&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, List all Checkout Sessions (&lt;code&gt;created&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; filters): &lt;a href="https://docs.stripe.com/api/checkout/sessions/list" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/checkout/sessions/list&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Health alerts (basic tier 402/500 for all users, advanced tier for Growth, Premium or Enterprise support plans, webhook latency alerts): &lt;a href="https://docs.stripe.com/health-alerts" rel="noopener noreferrer"&gt;https://docs.stripe.com/health-alerts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>backend</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Can someone fake a Stripe payment to your app? Only if your webhook skips the signature check</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:11:47 +0000</pubDate>
      <link>https://dev.to/webhookproof/can-someone-fake-a-stripe-payment-to-your-app-only-if-your-webhook-skips-the-signature-check-n4o</link>
      <guid>https://dev.to/webhookproof/can-someone-fake-a-stripe-payment-to-your-app-only-if-your-webhook-skips-the-signature-check-n4o</guid>
      <description>&lt;p&gt;Here's a question worth asking about any app that sells something through Stripe: what happens if someone who never paid sends your webhook endpoint a JSON body that &lt;em&gt;looks&lt;/em&gt; like a completed checkout?&lt;/p&gt;

&lt;p&gt;If the endpoint checks Stripe's signature, nothing happens. The request is rejected. If it doesn't, your app grants access, sends the download link, or writes the order, exactly as if the payment were real. This post covers why that works, what Stripe recommends against it, and how to test your own endpoint in one command. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe spells out the risk
&lt;/h2&gt;

&lt;p&gt;From Stripe's webhook docs: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records. Always verify that webhook events originate from Stripe before acting on them."&lt;/p&gt;

&lt;p&gt;A webhook endpoint is just a public URL that accepts &lt;code&gt;POST&lt;/code&gt; requests. Nothing about HTTP proves who sent the request. The JSON body carries no proof either: anyone can type &lt;code&gt;"type": "checkout.session.completed"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a hidden URL doesn't help
&lt;/h2&gt;

&lt;p&gt;It's tempting to think an obscure path is enough. In practice, webhook routes follow a few familiar patterns (&lt;code&gt;/api/webhooks/stripe&lt;/code&gt;, &lt;code&gt;/api/stripe/webhook&lt;/code&gt;), they show up in tutorials and generated code, and they end up in public repos. Treat the URL as public and rely on the checks below instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe says protects you
&lt;/h2&gt;

&lt;p&gt;Stripe's docs list two protections and say to "Use both of these protections":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signature verification.&lt;/strong&gt; "Stripe signs every webhook event by including a signature in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. Verify this signature using our official libraries or manually to confirm that the event wasn't sent or modified by a third party."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP allowlisting.&lt;/strong&gt; "Stripe sends webhook events from a set list of IP addresses. Configure your server or firewall to only accept webhook requests from these addresses." The current list is on Stripe's IP addresses page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signature is computed with a secret only Stripe and your server know. Stripe: "Stripe generates signatures using a hash-based message authentication code (HMAC) with SHA-256." Each endpoint has its own: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one."&lt;/p&gt;

&lt;p&gt;With the official Node library, verification is one call. It needs the raw request body, the header, and the endpoint's &lt;code&gt;whsec_&lt;/code&gt; secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js App Router route handler&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&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;Request&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// raw body, not parsed JSON&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;sig&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&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="na"&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Only now is it safe to act on event.type&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw body matters. Stripe: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail."&lt;/p&gt;

&lt;p&gt;If you verify manually instead of with a library, Stripe adds a detail that's easy to miss: "To protect against timing attacks, use a constant-time-string comparison to compare the expected signature to each of the received signatures."&lt;/p&gt;

&lt;h2&gt;
  
  
  What about replaying a real event?
&lt;/h2&gt;

&lt;p&gt;A signature stops forged events. A captured &lt;em&gt;real&lt;/em&gt; event is a different attack, and Stripe covers it too. "A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. To mitigate such attacks, Stripe includes a timestamp in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. Because this timestamp is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature."&lt;/p&gt;

&lt;p&gt;The libraries enforce it for you: "Our libraries have a default tolerance of 5 minutes between the timestamp and the current time." Leave that tolerance alone. Stripe warns: "Don't use a tolerance value of &lt;code&gt;0&lt;/code&gt;. Using a tolerance value of &lt;code&gt;0&lt;/code&gt; disables the recency check entirely."&lt;/p&gt;

&lt;h2&gt;
  
  
  A second check that a forged event can't pass
&lt;/h2&gt;

&lt;p&gt;Stripe's fulfillment guide already builds in a defense. Its &lt;code&gt;fulfill_checkout&lt;/code&gt; function is required to "Retrieve the Checkout Session from the API with the &lt;code&gt;line_items&lt;/code&gt; property expanded" and "Check the &lt;code&gt;payment_status&lt;/code&gt; property to determine if it requires fulfillment."&lt;/p&gt;

&lt;p&gt;That means fulfillment trusts the answer from Stripe's API, called with your secret key, not the contents of the incoming request. A made-up session ID won't come back from the API as paid.&lt;/p&gt;

&lt;p&gt;The same applies to the success page. Stripe's guide has the landing page "extract the Checkout Session ID from the URL" and then "Run your &lt;code&gt;fulfill_checkout&lt;/code&gt; function with the ID provided", which retrieves and checks the session. A page that grants access just because someone loaded &lt;code&gt;/success?session_id=...&lt;/code&gt;, without that lookup, is the same hole in a different place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your own endpoint in one command
&lt;/h2&gt;

&lt;p&gt;Send an unsigned request with an empty JSON body to &lt;strong&gt;your own&lt;/strong&gt; endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://yourdomain.com/api/webhooks/stripe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;400&lt;/strong&gt; (or another &lt;code&gt;4xx&lt;/code&gt; from your signature check): good. Unsigned requests are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;200&lt;/strong&gt;: the endpoint accepts requests without a valid signature. Fix this before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3xx, 401, 403, 404&lt;/strong&gt;: something in front of your code answered first (a redirect, an auth wall, a wrong path). That's a separate problem, and it usually means Stripe's real events aren't getting through either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only test endpoints you own or are authorized to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every event is verified with the official library (or a constant-time manual check) before any action&lt;/li&gt;
&lt;li&gt;[ ] Verification uses the raw request body and the &lt;code&gt;whsec_&lt;/code&gt; secret for this endpoint and this mode&lt;/li&gt;
&lt;li&gt;[ ] Signature failures return &lt;code&gt;400&lt;/code&gt; and do nothing else&lt;/li&gt;
&lt;li&gt;[ ] The timestamp tolerance is the default, never &lt;code&gt;0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Requests are limited to Stripe's published webhook IP addresses where your host allows it&lt;/li&gt;
&lt;li&gt;[ ] Fulfillment retrieves the Checkout Session from the API and checks &lt;code&gt;payment_status&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] The success page never grants access from the URL alone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WebhookProof's free checker runs the unsigned-request test above for you: paste a webhook URL and it sends 3 harmless requests to report whether Stripe can reach the endpoint and whether it rejects unsigned events. The full write-up is here: &lt;a href="https://webhookproof.com/guides/can-someone-fake-a-stripe-payment" rel="noopener noreferrer"&gt;Can someone fake a payment to your app?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-26.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (fake-event risk, use both protections, IP allowlisting, signature verification, unique secret per endpoint and mode, raw body, HMAC SHA-256, constant-time comparison, replay attacks, 5-minute default tolerance, don't use &lt;code&gt;0&lt;/code&gt;): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, IP addresses (webhook source IPs): &lt;a href="https://docs.stripe.com/ips" rel="noopener noreferrer"&gt;https://docs.stripe.com/ips&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Fulfill orders, full-page Checkout (retrieve the Checkout Session, check &lt;code&gt;payment_status&lt;/code&gt;, landing page extracts the session ID and runs &lt;code&gt;fulfill_checkout&lt;/code&gt;): &lt;a href="https://docs.stripe.com/checkout/fulfillment?ui=full-page" rel="noopener noreferrer"&gt;https://docs.stripe.com/checkout/fulfillment?ui=full-page&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>Stripe sent the same webhook twice. Make fulfillment idempotent before it costs you</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:06:41 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-sent-the-same-webhook-twice-make-fulfillment-idempotent-before-it-costs-you-3cb7</link>
      <guid>https://dev.to/webhookproof/stripe-sent-the-same-webhook-twice-make-fulfillment-idempotent-before-it-costs-you-3cb7</guid>
      <description>&lt;p&gt;Signs that something ran twice: a customer gets two welcome emails, two license keys, or a credit balance that doubled. A digital-download store sees two orders in its database for one payment. Nothing failed. Your webhook handler did what it was told, twice.&lt;/p&gt;

&lt;p&gt;Stripe documents this behavior. This post covers why one payment can reach your fulfillment code more than once, what to key deduplication on, and a small pattern that holds up even when two calls arrive at the same moment. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same payment arrives more than once
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Stripe can redeliver an event.&lt;/strong&gt; Stripe's webhook docs: "Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipts by logging the event IDs you've processed, and then not processing already-logged events."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Retries are normal.&lt;/strong&gt; If your endpoint doesn't return &lt;code&gt;2xx&lt;/code&gt; in time, Stripe retries. In live mode it attempts delivery "for up to three days with an exponential back off". A handler that did the work and then timed out before responding will get the same event again. Manual resends add more: "Manually resending an event that failed to reach a webhook endpoint doesn't cancel Stripe's automatic retry behavior, even if it results in a &lt;code&gt;2xx&lt;/code&gt; status code."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Sometimes there are two different events.&lt;/strong&gt; "In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in &lt;code&gt;data.object&lt;/code&gt; along with the &lt;code&gt;event.type&lt;/code&gt;." An event-ID check alone won't catch that case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your own success page is a second caller.&lt;/strong&gt; Stripe's fulfillment guide recommends calling fulfillment both from the webhook and from the Checkout landing page, so the customer gets access right away. It then warns: "your &lt;code&gt;fulfill_checkout&lt;/code&gt; function might be called multiple times, possibly concurrently, for the same Checkout Session."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Order isn't guaranteed.&lt;/strong&gt; "Stripe doesn't guarantee the delivery of events in the order that they're generated." Stripe also warns that distinct events can share a timestamp, so "don't use &lt;code&gt;created&lt;/code&gt; to determine event order or whether you've already processed an event. Track event IDs to identify duplicate deliveries instead."&lt;/p&gt;

&lt;h2&gt;
  
  
  What to key on
&lt;/h2&gt;

&lt;p&gt;For one-time purchases through Checkout, Stripe's own requirements for &lt;code&gt;fulfill_checkout&lt;/code&gt; give the answer. It must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Correctly handle being called multiple times with the same Checkout Session ID."&lt;/li&gt;
&lt;li&gt;"Accept a Checkout Session ID as an argument."&lt;/li&gt;
&lt;li&gt;"Retrieve the Checkout Session from the API with the &lt;code&gt;line_items&lt;/code&gt; property expanded."&lt;/li&gt;
&lt;li&gt;"Check the &lt;code&gt;payment_status&lt;/code&gt; property to determine if it requires fulfillment."&lt;/li&gt;
&lt;li&gt;"Perform fulfillment of the line items."&lt;/li&gt;
&lt;li&gt;"Record fulfillment status for the provided Checkout Session."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the &lt;strong&gt;Checkout Session ID&lt;/strong&gt; is the natural key. It's the same whether the call comes from &lt;code&gt;checkout.session.completed&lt;/code&gt;, &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt;, a retry, a resend, or the success page. An event ID only dedupes a single event. The session ID dedupes the payment.&lt;/p&gt;

&lt;p&gt;Use event IDs as well if you log or process other event types. That's the guard Stripe describes for repeated deliveries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: claim first, then fulfill
&lt;/h2&gt;

&lt;p&gt;"Check whether it's done, then do it" has a race: two concurrent calls can both see "not done" and both proceed. Let the database decide instead, with a unique constraint and a single atomic insert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Postgres (works the same on Supabase)&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;fulfillments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;checkout_session_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Called from the webhook AND the success page&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fulfillCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1) Try to claim this session. Only one caller can win.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into fulfillments (checkout_session_id)
     values ($1)
     on conflict (checkout_session_id) do nothing
     returning checkout_session_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// already claimed: do nothing&lt;/span&gt;

  &lt;span class="c1"&gt;// 2) Confirm it's paid, per Stripe's fulfillment guide&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;line_items&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unpaid&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`delete from fulfillments where checkout_session_id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// a later async_payment_succeeded event will call this again&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3) Grant access / send the key / write the order&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;grantAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// 4) Record it&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`update fulfillments set status = 'fulfilled' where checkout_session_id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL's docs describe the key piece: "&lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; simply avoids inserting a row as its alternative action," and &lt;code&gt;RETURNING&lt;/code&gt; returns values "based on each row actually inserted". If no row comes back, another call already claimed the session.&lt;/p&gt;

&lt;p&gt;Two notes on this sketch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failures in step 3.&lt;/strong&gt; If &lt;code&gt;grantAccess&lt;/code&gt; throws, the row stays at &lt;code&gt;processing&lt;/code&gt;. Either delete the row so a retry can claim it again, or have a cleanup job retry rows stuck in &lt;code&gt;processing&lt;/code&gt;. Stripe's own recovery guide uses the same three states (&lt;code&gt;is_processing_or_processed&lt;/code&gt;, &lt;code&gt;mark_as_processing&lt;/code&gt;, &lt;code&gt;mark_as_processed&lt;/code&gt;) for its backlog script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates still get &lt;code&gt;200&lt;/code&gt;.&lt;/strong&gt; When the handler sees a session it already processed, it should return success. Stripe's guide: "When your endpoint receives an event you've already processed, skip it and return a &lt;code&gt;200&lt;/code&gt; response to stop further retries." Returning an error just brings the event back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Don't confuse this with &lt;code&gt;Idempotency-Key&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Stripe's API has idempotency keys, and they solve a different problem: &lt;strong&gt;your&lt;/strong&gt; requests &lt;strong&gt;to&lt;/strong&gt; Stripe. "When creating or updating an object, use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object." Keys can be "up to 255 characters long", and for API v1 they can be pruned "after they're at least 24 hours old".&lt;/p&gt;

&lt;p&gt;Use them when fulfillment itself calls Stripe, for example to create a refund or a credit. They won't dedupe incoming webhooks. Only your own unique constraint does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the handler fast
&lt;/h2&gt;

&lt;p&gt;Many duplicates come from timeouts. Stripe: "Your endpoint must quickly return a successful status code (&lt;code&gt;2xx&lt;/code&gt;) before any complex logic that could cause a timeout." For heavy work (PDF generation, sending email, calls to other APIs), Stripe suggests handling events with "an asynchronous queue". Verify the signature, record the session ID, queue the work, and respond. Fewer timeouts mean fewer retries to dedupe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Fulfillment is keyed on the Checkout Session ID, with a unique constraint in the database&lt;/li&gt;
&lt;li&gt;[ ] The claim is a single atomic insert (&lt;code&gt;on conflict do nothing&lt;/code&gt;), not a read-then-write&lt;/li&gt;
&lt;li&gt;[ ] Webhook and success page call the same function&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;payment_status&lt;/code&gt; is checked, and &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; is handled for delayed methods&lt;/li&gt;
&lt;li&gt;[ ] Already-processed events return &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Outbound Stripe writes in fulfillment send an &lt;code&gt;Idempotency-Key&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] The handler responds before slow work runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No outside scan can see whether your fulfillment is idempotent, because it lives in your code and database. WebhookProof's free checker covers the parts that are visible from outside: whether Stripe can reach your endpoint and whether it rejects unsigned events, using 3 harmless requests. The duplicate-event steps are also written up here: &lt;a href="https://webhookproof.com/guides/duplicate-stripe-webhook-events" rel="noopener noreferrer"&gt;Duplicate Stripe webhook events: make fulfillment idempotent&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-18.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (duplicate deliveries, two Event objects, ordering, &lt;code&gt;created&lt;/code&gt; warning, retries, manual resend doesn't cancel retries, respond with 2xx quickly, async queue): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Fulfill orders, hosted Checkout (&lt;code&gt;fulfill_checkout&lt;/code&gt; called multiple times and concurrently, six requirements, &lt;code&gt;payment_status&lt;/code&gt;, async payment events, landing page plus webhook): &lt;a href="https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted" rel="noopener noreferrer"&gt;https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Manage webhook endpoints: process undelivered events (processing/processed states, return 200 for already-processed events): &lt;a href="https://docs.stripe.com/webhooks/signature" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks/signature&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API reference, Idempotent requests (outbound idempotency keys, 255 characters, 24-hour pruning): &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/idempotent_requests&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL, INSERT (&lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, &lt;code&gt;RETURNING&lt;/code&gt;): &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/sql-insert.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
    <item>
      <title>Stripe webhooks broke the day you went live? Check which whsec_ you're using</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Wed, 21 Oct 2026 14:06:22 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-webhooks-broke-the-day-you-went-live-check-which-whsec-youre-using-3e0a</link>
      <guid>https://dev.to/webhookproof/stripe-webhooks-broke-the-day-you-went-live-check-which-whsec-youre-using-3e0a</guid>
      <description>&lt;p&gt;Everything passed in test mode. You switched to live keys, made a real purchase, and the webhook started failing. Stripe's delivery log shows &lt;code&gt;400&lt;/code&gt; on every event, and your logs say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No signatures found matching the expected signature for payload.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your code didn't change. The secret did, or rather it should have and didn't. This post covers why each Stripe endpoint and mode has its own signing secret, the three secrets that usually get mixed up, and how to swap the right one in without dropping events. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  One endpoint, one mode, one secret
&lt;/h2&gt;

&lt;p&gt;Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one you want to verify signatures on."&lt;/p&gt;

&lt;p&gt;Stripe's API keys page also notes that webhook secrets aren't API keys: "Webhook signing secrets aren't API keys—they're per-webhook secrets that your webhook receiver uses to authenticate that webhooks actually came from Stripe."&lt;/p&gt;

&lt;p&gt;So changing &lt;code&gt;sk_test_&lt;/code&gt; to &lt;code&gt;sk_live_&lt;/code&gt; doesn't touch your &lt;code&gt;whsec_&lt;/code&gt; value at all. Stripe's go-live steps list it as its own step: "If you use webhooks, update each webhook endpoint's URL and copy the new signing secret from the Webhooks section of the Dashboard."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the error looks like this
&lt;/h2&gt;

&lt;p&gt;Stripe signs every event with an HMAC-SHA256 of the timestamp and the raw body, keyed with that endpoint's secret. Your library recomputes the HMAC with the secret you give it and compares the two. With the wrong secret the HMACs can never match, so the library raises the generic error above.&lt;/p&gt;

&lt;p&gt;Stripe's troubleshooting page says that if you see it, "at least one of the three parameters you passed is incorrect": the raw body, the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, or the endpoint secret. It also names the usual suspect: "The most common cause is using the wrong endpoint secret."&lt;/p&gt;

&lt;h2&gt;
  
  
  The three secrets that get mixed up
&lt;/h2&gt;

&lt;p&gt;All three start with &lt;code&gt;whsec_&lt;/code&gt;, which is why it's easy to mix them up.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;stripe listen&lt;/code&gt; secret.&lt;/strong&gt; The CLI prints its own: "Ready! Your webhook signing secret is '...'". Stripe's troubleshooting page: "The secret for a CLI-forwarded endpoint is different from the secret for a Dashboard-managed endpoint—don't mix them up." It belongs in &lt;code&gt;.env.local&lt;/code&gt;, not in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The test-mode (sandbox) endpoint secret.&lt;/strong&gt; It verifies sandbox events only. Stripe: "Each mode has its own set of API keys, and objects in one mode aren't accessible to the other."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live endpoint secret.&lt;/strong&gt; Shown on the live-mode endpoint in Workbench → Webhooks → &lt;em&gt;Reveal secret&lt;/em&gt;. This is the only one that verifies live events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fourth situation catches teams with more than one endpoint, such as staging and production, or two separate handlers. Each endpoint has its own secret, so copying one endpoint's secret into the other endpoint's environment fails the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell which one you have
&lt;/h2&gt;

&lt;p&gt;Don't print the secret to your logs. Compare it by eye instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Stripe, switch to &lt;strong&gt;live mode&lt;/strong&gt;, open Workbench → Webhooks, select the endpoint whose URL matches your production handler, and click &lt;em&gt;Reveal secret&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;In your host's environment settings, open the production value of &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; (or whatever your code reads) and compare the first and last few characters.&lt;/li&gt;
&lt;li&gt;Check that the code reads the variable you think it does. A fallback like &lt;code&gt;process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_...'&lt;/code&gt; holding a hardcoded test secret causes the same failure. Stripe's key guidance applies here too: "Don't put keys in source code or configuration files checked into version control."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some quick tells in the Stripe delivery log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live events fail with 400, test events pass&lt;/strong&gt; → live endpoint, test secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing fails in Stripe, but production events never show up&lt;/strong&gt; → there may be no live endpoint yet. Stripe keeps sandbox and live objects apart, so an endpoint created in a sandbox won't receive live events. Create one in live mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures began right after "Roll secret"&lt;/strong&gt; → the old secret expired before your server got the new one (see below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Swapping the secret without losing events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Update the variable, then make sure the running code sees it.&lt;/strong&gt; On Vercel: "Any change you make to environment variables are not applied to previous deployments, they only apply to new deployments." Redeploy production after you change the variable. On Supabase Edge Functions, &lt;code&gt;supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...&lt;/code&gt; takes effect without a redeploy, per Supabase's secrets docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When rolling a secret, use the overlap window.&lt;/strong&gt; Stripe lets you "delay its expiration for up to 24 hours to allow yourself time to update the verification code on your server. During this time, multiple secrets are active for the endpoint. Stripe generates one signature per secret until expiration." Deploy the new secret within that window and nothing fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recover what failed.&lt;/strong&gt; In live mode Stripe retries failed deliveries "for up to three days with an exponential back off". Anything older, or anything you want handled now, can be resent from the Dashboard "for up to 15 days after the event creation", or with &lt;code&gt;stripe events resend&lt;/code&gt; for up to 30 days. Stripe makes "a new signature and timestamp for the new delivery attempt", so resent events verify against the new secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fix it by turning verification off
&lt;/h2&gt;

&lt;p&gt;When this error shows up on launch day, it's tempting to comment out the signature check "for now". Stripe's docs explain the cost: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records." Keep returning &lt;code&gt;400&lt;/code&gt; when verification fails, as Stripe's own examples do, and fix the secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go-live checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A live-mode endpoint exists for the production URL, subscribed to the events you handle&lt;/li&gt;
&lt;li&gt;[ ] Production &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; = that live endpoint's &lt;code&gt;whsec_&lt;/code&gt; (not the sandbox one, not &lt;code&gt;stripe listen&lt;/code&gt;'s)&lt;/li&gt;
&lt;li&gt;[ ] One secret per endpoint: staging and production don't share&lt;/li&gt;
&lt;li&gt;[ ] No hardcoded fallback secret in code&lt;/li&gt;
&lt;li&gt;[ ] Redeployed after changing the variable (Vercel), or set with &lt;code&gt;supabase secrets set&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A resent live event shows &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WebhookProof's free checker can't see your secret, and no outside scan can. It does confirm the half of the problem that's visible from outside: whether Stripe can reach your live endpoint and whether that endpoint rejects unsigned events, using 3 harmless requests. The go-live steps above are also in the guide: &lt;a href="https://webhookproof.com/guides/stripe-test-vs-live-webhook-secret" rel="noopener noreferrer"&gt;Test vs live Stripe webhook secret&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-18.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (secret per endpoint and per mode, &lt;code&gt;stripe listen&lt;/code&gt; secret, HMAC-SHA256 signing, rolling secrets and the 24-hour overlap, retries, resend windows, new signature per retry, why to verify): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Manage webhook endpoints: resolve signature verification errors (the error text, "at least one of the three parameters", wrong secret most common, CLI vs Dashboard secret): &lt;a href="https://docs.stripe.com/webhooks/signature" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks/signature&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, API keys (signing secrets aren't API keys, sandbox vs live mode, go-live: copy each endpoint's new signing secret, don't commit keys): &lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;https://docs.stripe.com/keys&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vercel, Environment variables (changes apply only to new deployments): &lt;a href="https://vercel.com/docs/environment-variables" rel="noopener noreferrer"&gt;https://vercel.com/docs/environment-variables&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Environment variables / secrets (&lt;code&gt;supabase secrets set&lt;/code&gt;, no redeploy needed): &lt;a href="https://supabase.com/docs/guides/functions/secrets" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/secrets&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Customer paid in your Lovable or Bolt app but nothing unlocked? Where to look first</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Mon, 19 Oct 2026 11:53:09 +0000</pubDate>
      <link>https://dev.to/webhookproof/customer-paid-in-your-lovable-or-bolt-app-but-nothing-unlocked-where-to-look-first-mgo</link>
      <guid>https://dev.to/webhookproof/customer-paid-in-your-lovable-or-bolt-app-but-nothing-unlocked-where-to-look-first-mgo</guid>
      <description>&lt;p&gt;A customer writes in: they paid, and they still can't get in. Stripe shows a successful payment. Your app shows a locked screen.&lt;/p&gt;

&lt;p&gt;In apps built with Lovable or Bolt, the fix depends on how the builder connected Stripe, because the two take different routes. This post covers how each one decides a customer has paid, where that breaks, and what to paste back into the builder. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: find out which setup you have
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lovable&lt;/strong&gt; offers two routes. Its docs say "most projects should use built-in payments", where "Lovable provisions the Stripe or Paddle account and manages keys, webhooks, and test and live environments for you." The other route is "Connect your own Stripe account", where you supply the API key. A project "can use one or the other, not both at the same time."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bolt&lt;/strong&gt; has a single Stripe integration that "requires Supabase as your database provider". When you apply it, Bolt "automatically creates the Supabase edge functions to handle" checkout session creation, "payment webhook processing", and subscription management.&lt;/p&gt;

&lt;p&gt;If you're on Lovable built-in payments, Lovable runs the webhook side, so start in its Payments view. The rest of this post is about the setups where the payment wiring sits in your project: Lovable with your own Stripe account, and Bolt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lovable with your own Stripe account: no webhooks by default
&lt;/h2&gt;

&lt;p&gt;Lovable's docs are explicit: "By default, the integration does not use webhooks: your app checks payment and subscription status directly with Stripe."&lt;/p&gt;

&lt;p&gt;That gives two common failure modes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. One-time purchases granted only on the success page
&lt;/h3&gt;

&lt;p&gt;For a one-time purchase, the buyer pays in Stripe Checkout and then "returns to a payment success page in your app". If the code that grants access only runs when that page loads, a buyer who closes the tab never gets access.&lt;/p&gt;

&lt;p&gt;Stripe warns about exactly this: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page. For example, a customer can pay successfully and then lose their internet connection before your landing page loads." Stripe's fix is a webhook that calls the same fulfillment function, with the landing page as a second, faster path.&lt;/p&gt;

&lt;p&gt;Lovable supports that when you ask for it. It "writes the webhook function, and you finish the setup in your Stripe dashboard": create an event destination for the function's URL, choose the events, and add the signing secret when Lovable asks.&lt;/p&gt;

&lt;p&gt;Prompt to paste into Lovable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a Stripe webhook function that handles &lt;code&gt;checkout.session.completed&lt;/code&gt; and &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt;, verifies the &lt;code&gt;Stripe-Signature&lt;/code&gt; header against the signing secret, and grants access through the same function the success page uses. Make that function safe to run twice for the same Checkout Session ID.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Subscriptions matched by email
&lt;/h3&gt;

&lt;p&gt;For subscriptions, "subscriptions are matched to users by email address. A user's subscription is recognized when the email they sign in with matches the email on their Stripe customer record." If a customer pays as &lt;code&gt;jane@work.com&lt;/code&gt; and logs in as &lt;code&gt;jane@gmail.com&lt;/code&gt;, the check finds no subscription.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; in Stripe, open the customer record and compare its email with the one the user signs in with. If they differ, change one of them, or ask Lovable to link the Stripe customer ID to the user at checkout rather than relying on the email match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bolt: the webhook function exists, so check that it runs
&lt;/h2&gt;

&lt;p&gt;Bolt's docs say it "automatically handles webhook endpoint registration and signature verification. You don't need to manually configure webhook endpoints in your Stripe dashboard." With the plumbing generated for you, the useful question is whether events are actually arriving and succeeding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; in Stripe, open Workbench → Webhooks, select the endpoint, and open &lt;strong&gt;Event deliveries&lt;/strong&gt;. Stripe's docs say this tab lists each event as &lt;code&gt;Delivered&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;, along with "the HTTP status code of the delivery attempt".&lt;/p&gt;

&lt;p&gt;Then work from the status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the status code (both builders)
&lt;/h2&gt;

&lt;p&gt;These apply whenever your webhook is a Supabase Edge Function, which is how Bolt builds it, and how Lovable builds it on your own Supabase project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;401&lt;/code&gt; with &lt;code&gt;"Missing authorization header"&lt;/code&gt; or &lt;code&gt;"Invalid Token..."&lt;/code&gt;:&lt;/strong&gt; Supabase's troubleshooting guide says these messages "come from the legacy auth verification check", which "runs before your code. When it fails, your function never executes." Stripe sends no Supabase token, so the platform rejects the event. Fix: turn off JWT verification for the webhook function only, then verify the Stripe signature inside it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Stripe webhook Edge Function returns 401 before it runs. Set &lt;code&gt;verify_jwt = false&lt;/code&gt; for that function only in &lt;code&gt;supabase/config.toml&lt;/code&gt;, keep Stripe signature verification with &lt;code&gt;constructEventAsync&lt;/code&gt; on the raw body (&lt;code&gt;await req.text()&lt;/code&gt;), and redeploy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;400&lt;/code&gt; with a signature error:&lt;/strong&gt; the function runs but the signature doesn't match. The most common cause, per Stripe, is "using the wrong endpoint secret", for example a test secret on the live endpoint (next section).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;200&lt;/code&gt;, but still no access:&lt;/strong&gt; the event arrived and your function returned success without granting access. Check that the endpoint subscribes to the event your code handles (&lt;code&gt;checkout.session.completed&lt;/code&gt; for one-time payments), and look in the function logs for the database write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No delivery at all:&lt;/strong&gt; the live-mode endpoint may not exist, or may not subscribe to that event type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test vs live: the go-live trap
&lt;/h2&gt;

&lt;p&gt;Both builders tell you to start with test keys. Bolt: "Use test keys for development. Test keys start with &lt;code&gt;sk_test_&lt;/code&gt;." Stripe's docs explain why switching modes takes more than a new key: "Each mode has its own set of API keys, and objects in one mode aren't accessible to the other." Webhook secrets differ too: "If you use the same endpoint for both test and live API keys, the secret is different for each one."&lt;/p&gt;

&lt;p&gt;Lovable's go-live steps cover the price side: "Price IDs differ between test and live mode, so ask Lovable to switch your app to the live price IDs." If you added a webhook, also paste in the live endpoint's &lt;code&gt;whsec_&lt;/code&gt; secret. The test one won't verify live events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing customers who already paid
&lt;/h2&gt;

&lt;p&gt;Once the endpoint works, recover the missed events. Stripe's docs say a Dashboard &lt;strong&gt;Resend&lt;/strong&gt; works "for up to 15 days after the event creation", and the CLI's &lt;code&gt;stripe events resend&lt;/code&gt; works for 30. In live mode Stripe also retries failed events "for up to three days". Make sure the grant is safe to run twice before you resend, because Stripe says your fulfillment function "might be called multiple times, possibly concurrently, for the same Checkout Session."&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Know which route you're on: Lovable built-in, Lovable with your own Stripe, or Bolt&lt;/li&gt;
&lt;li&gt;[ ] Access is granted from a webhook, not only from the success page&lt;/li&gt;
&lt;li&gt;[ ] Subscriptions: the login email matches the Stripe customer email (or you match on customer ID)&lt;/li&gt;
&lt;li&gt;[ ] Event deliveries show &lt;code&gt;200&lt;/code&gt;, not &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;400&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Live mode: live key, live price IDs, live endpoint, live &lt;code&gt;whsec_&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Granting access twice for the same Checkout Session does nothing extra&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WebhookProof's free checker sends 3 harmless requests to your webhook URL and tells you whether Stripe can reach it (401s, redirects, login walls) and whether it accepts unsigned events. Each issue comes with a prompt to paste into your builder. More detail in the guide: &lt;a href="https://webhookproof.com/guides/supabase-stripe-webhook-lovable-bolt" rel="noopener noreferrer"&gt;Stripe webhooks with Supabase, Lovable and Bolt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc., Lovable, Bolt or Supabase, Inc. Quotes are from the linked documentation as read on 2026-10-18.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lovable, Connect your own Stripe account (built-in vs own account, no webhooks by default, success page, email matching, webhook setup, live price IDs): &lt;a href="https://docs.lovable.dev/integrations/stripe" rel="noopener noreferrer"&gt;https://docs.lovable.dev/integrations/stripe&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bolt, Stripe for payments (Supabase requirement, auto-created edge functions, automatic webhook registration and verification, &lt;code&gt;sk_test_&lt;/code&gt; keys): &lt;a href="https://support.bolt.new/integrations/stripe" rel="noopener noreferrer"&gt;https://support.bolt.new/integrations/stripe&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Fulfill orders, hosted Checkout (don't rely on the landing page, &lt;code&gt;fulfill_checkout&lt;/code&gt; may be called multiple times, async payment events): &lt;a href="https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted" rel="noopener noreferrer"&gt;https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hosted&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (Event deliveries tab, per-mode secrets, retries, resend windows): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Manage webhook endpoints (wrong endpoint secret is the most common signature error): &lt;a href="https://docs.stripe.com/webhooks/signature" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks/signature&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, API keys (sandbox vs live mode, objects not shared across modes): &lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;https://docs.stripe.com/keys&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Edge Function 401 error response (legacy JWT check runs before your code): &lt;a href="https://supabase.com/docs/guides/troubleshooting/edge-function-401-error-response" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/troubleshooting/edge-function-401-error-response&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Function Configuration (&lt;code&gt;verify_jwt = false&lt;/code&gt; per function): &lt;a href="https://supabase.com/docs/guides/functions/function-configuration" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/function-configuration&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>supabase</category>
      <category>webhooks</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Stripe webhook shows 307 or 308 on Vercel / Next.js? It's a redirect, and Stripe won't follow it</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:57:37 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-webhook-shows-307-or-308-on-vercel-nextjs-its-a-redirect-and-stripe-wont-follow-it-4dhh</link>
      <guid>https://dev.to/webhookproof/stripe-webhook-shows-307-or-308-on-vercel-nextjs-its-a-redirect-and-stripe-wont-follow-it-4dhh</guid>
      <description>&lt;p&gt;The checkout works and the money arrives, but your app never finds out. Open the webhook endpoint in Stripe and every delivery attempt says &lt;code&gt;307&lt;/code&gt; or &lt;code&gt;308&lt;/code&gt;. Your route handler logs are empty.&lt;/p&gt;

&lt;p&gt;That's not a bug in your handler. Something in front of it answered with a redirect, and Stripe doesn't follow redirects. This post covers where those redirects come from on Next.js and Vercel, how to find the exact URL Stripe should call, and how to check the fix. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a redirect counts as a failure
&lt;/h2&gt;

&lt;p&gt;Stripe's webhook docs list this under delivery errors: "&lt;code&gt;302&lt;/code&gt; ERR (or other &lt;code&gt;3xx&lt;/code&gt; status)... The destination server attempted to redirect the request to another location. We consider redirect responses to webhook requests as failures." Stripe's recommended fix is short: "Set the webhook endpoint destination to the URL resolved by the redirect."&lt;/p&gt;

&lt;p&gt;In other words, don't fight the redirect. Find where it ends up, and register that URL.&lt;/p&gt;

&lt;p&gt;The same docs say a registered endpoint must be a "publicly accessible HTTPS URL", and in live mode Stripe retries a failed event "for up to three days with an exponential back off". So a redirect doesn't lose the event right away, but every retry hits the same redirect until the URL is corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 307 and 308 specifically
&lt;/h2&gt;

&lt;p&gt;Next.js explains its choice in the &lt;code&gt;redirects&lt;/code&gt; reference. Older 301/302 redirects let many browsers switch a &lt;code&gt;POST&lt;/code&gt; to a &lt;code&gt;GET&lt;/code&gt;, so "Next.js uses the 307 temporary redirect, and 308 permanent redirect status codes to explicitly preserve the request method used." Vercel's redirect docs recommend 307 or 308 for the same reason.&lt;/p&gt;

&lt;p&gt;When you see a 307 or 308 in Stripe's delivery log, the response came from a Next.js or Vercel redirect rule, not from your code. There are four usual sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: the trailing slash
&lt;/h2&gt;

&lt;p&gt;From the Next.js &lt;code&gt;trailingSlash&lt;/code&gt; docs: "By default Next.js will redirect URLs with trailing slashes to their counterpart without a trailing slash. For example &lt;code&gt;/about/&lt;/code&gt; will redirect to &lt;code&gt;/about&lt;/code&gt;." If you set &lt;code&gt;trailingSlash: true&lt;/code&gt;, it works the other way round.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;skipTrailingSlashRedirect&lt;/code&gt; reference spells out the status codes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;trailingSlash&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/webhooks/stripe/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;308&lt;/code&gt; to &lt;code&gt;/api/webhooks/stripe&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/webhooks/stripe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;308&lt;/code&gt; to &lt;code&gt;/api/webhooks/stripe/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; copy the endpoint URL from Stripe, compare the end of it with your &lt;code&gt;trailingSlash&lt;/code&gt; setting, and edit the endpoint URL to match. You don't need to change your Next.js config for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: apex vs www (domain redirect)
&lt;/h2&gt;

&lt;p&gt;Vercel's domain docs say that if a visitor uses your domain with or without &lt;code&gt;www&lt;/code&gt;, Vercel "will attempt to redirect automatically", and you can also add the redirect yourself under Domains in Project Settings. Vercel recommends "using the &lt;code&gt;www&lt;/code&gt; subdomain as your primary domain, with a redirect from the non-&lt;code&gt;www&lt;/code&gt; domain to it."&lt;/p&gt;

&lt;p&gt;So if your project's primary domain is &lt;code&gt;www.example.com&lt;/code&gt; and the Stripe endpoint says &lt;code&gt;https://example.com/api/webhooks/stripe&lt;/code&gt;, every delivery gets redirected before it reaches your function. The reverse happens if apex is primary and Stripe has the &lt;code&gt;www&lt;/code&gt; URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; in Vercel, open Project Settings → Domains and see which domain is primary and which one redirects. Put the primary host in the Stripe endpoint URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: a &lt;code&gt;redirects()&lt;/code&gt; rule that matches the webhook path
&lt;/h2&gt;

&lt;p&gt;In the &lt;code&gt;redirects&lt;/code&gt; reference, &lt;code&gt;permanent: true&lt;/code&gt; "will use the 308 status code" and &lt;code&gt;permanent: false&lt;/code&gt; "will use the 307 status code". Redirects are also "checked before the filesystem", so a wide rule beats your route handler. A catch-all like &lt;code&gt;source: '/:path*'&lt;/code&gt; that moves traffic to a new domain or locale will catch &lt;code&gt;/api/webhooks/stripe&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;The same goes for redirects in &lt;code&gt;vercel.json&lt;/code&gt;, and for Vercel Firewall redirects. Vercel says a dashboard firewall redirect "creates a temporary 307 redirect", and that firewall redirects run "before CDN configuration redirects".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; narrow the rule so it skips the webhook path, or point Stripe at the destination the rule sends traffic to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: Proxy (formerly Middleware) redirecting to login
&lt;/h2&gt;

&lt;p&gt;Next.js renamed &lt;code&gt;middleware&lt;/code&gt; to &lt;code&gt;proxy&lt;/code&gt; in v16. Its docs say it's commonly used "for implementing custom server-side logic like authentication", and that "without a &lt;code&gt;matcher&lt;/code&gt;, Proxy runs on every request". Auth middleware that sends logged-out visitors to &lt;code&gt;/login&lt;/code&gt; with &lt;code&gt;NextResponse.redirect(...)&lt;/code&gt; treats Stripe as a logged-out visitor. Stripe has no session cookie, so it gets redirected.&lt;/p&gt;

&lt;p&gt;The docs' own negative-matcher example leaves out &lt;code&gt;api&lt;/code&gt;:&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;// proxy.ts (or middleware.ts on Next.js 15 and earlier)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; exclude the webhook route from the matcher, or return early for that path. In either case, the handler itself still has to verify the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, as Stripe's docs require.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more: double slashes
&lt;/h2&gt;

&lt;p&gt;Vercel's CDN "normalizes URLs containing consecutive slashes (e.g., &lt;code&gt;//&lt;/code&gt;) to single slashes and redirects with a &lt;code&gt;308&lt;/code&gt; status code." An endpoint pasted as &lt;code&gt;https://www.example.com//api/webhooks/stripe&lt;/code&gt; fails for that reason alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find the real URL
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; shows the redirect and where it points. Send a &lt;code&gt;POST&lt;/code&gt;, since that's what Stripe sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/api/webhooks/stripe/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first line is &lt;code&gt;HTTP/2 307&lt;/code&gt; or &lt;code&gt;HTTP/2 308&lt;/code&gt;, the &lt;code&gt;location:&lt;/code&gt; header is where the request is meant to go. Run the command again against that URL, and repeat until the response is no longer a 3xx. Vercel's redirect docs use the same method (&lt;code&gt;curl -I&lt;/code&gt; and look for the &lt;code&gt;Location&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;When you reach the final URL, an unsigned &lt;code&gt;POST&lt;/code&gt; like the one above should get a &lt;code&gt;400&lt;/code&gt;, because your handler rejected a request with no valid signature. If it gets a &lt;code&gt;200&lt;/code&gt;, your endpoint accepts unsigned events, which is a separate and worse problem.&lt;/p&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit the endpoint in Stripe (Workbench → Webhooks) and paste the final URL.&lt;/li&gt;
&lt;li&gt;Open a failed event and click &lt;strong&gt;Resend&lt;/strong&gt;. Stripe's docs say Dashboard resends work "for up to 15 days after the event creation" (or 30 days with &lt;code&gt;stripe events resend&lt;/code&gt; in the CLI).&lt;/li&gt;
&lt;li&gt;Check that the event delivery now shows &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same mistake can happen locally. If &lt;code&gt;stripe listen --forward-to localhost:3000/api/webhooks/stripe/&lt;/code&gt; uses a trailing slash your app redirects away from, the CLI shows the same 308.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Host in the Stripe endpoint = the primary domain in Vercel (apex vs &lt;code&gt;www&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Trailing slash in the Stripe endpoint matches &lt;code&gt;trailingSlash&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;redirects()&lt;/code&gt;, &lt;code&gt;vercel.json&lt;/code&gt; or Firewall rule matches the webhook path&lt;/li&gt;
&lt;li&gt;[ ] Proxy/Middleware matcher excludes the webhook route&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;curl -i -X POST&lt;/code&gt; to the registered URL returns a non-3xx (ideally &lt;code&gt;400&lt;/code&gt; for an unsigned request)&lt;/li&gt;
&lt;li&gt;[ ] A resent event shows &lt;code&gt;200&lt;/code&gt; in Event deliveries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather not follow redirects by hand, WebhookProof's free checker sends 3 harmless requests to your webhook URL and reports redirects, auth walls, and whether the endpoint accepts unsigned events. The guide for this case is here: &lt;a href="https://webhookproof.com/guides/stripe-webhook-307-308-redirect-vercel" rel="noopener noreferrer"&gt;Stripe webhook 307 / 308 redirect error (Vercel, Next.js)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. or Vercel Inc. Quotes are from the linked documentation as read on 2026-10-18.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (3xx counted as failures, "URL resolved by the redirect", public HTTPS, retries, resend windows, signature verification): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next.js, &lt;code&gt;trailingSlash&lt;/code&gt; (default redirect behavior): &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next.js, &lt;code&gt;skipTrailingSlashRedirect&lt;/code&gt; (308 status table): &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/skipTrailingSlashRedirect" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/api-reference/config/next-config-js/skipTrailingSlashRedirect&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next.js, &lt;code&gt;redirects&lt;/code&gt; (&lt;code&gt;permanent&lt;/code&gt; → 308/307, why 307/308, checked before the filesystem): &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/redirects" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/api-reference/config/next-config-js/redirects&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next.js, &lt;code&gt;proxy.js&lt;/code&gt; file convention (rename from middleware in v16, matcher behavior, negative matcher example): &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/api-reference/file-conventions/proxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vercel, Deploying &amp;amp; Redirecting Domains (automatic www/apex redirect, www recommended as primary): &lt;a href="https://vercel.com/docs/domains/working-with-domains/deploying-and-redirecting" rel="noopener noreferrer"&gt;https://vercel.com/docs/domains/working-with-domains/deploying-and-redirecting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vercel, Redirects (307/308 recommendation, firewall 307, double-slash 308 normalization, curl verification): &lt;a href="https://vercel.com/docs/redirects" rel="noopener noreferrer"&gt;https://vercel.com/docs/redirects&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>vercel</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Sun, 18 Oct 2026 23:56:07 +0000</pubDate>
      <link>https://dev.to/webhookproof/stripe-webhook-returns-401-on-a-supabase-edge-function-heres-the-fix-and-the-step-after-it-1b1k</link>
      <guid>https://dev.to/webhookproof/stripe-webhook-returns-401-on-a-supabase-edge-function-heres-the-fix-and-the-step-after-it-1b1k</guid>
      <description>&lt;p&gt;Checkout works. The customer pays. Stripe's delivery log for your webhook shows &lt;code&gt;401&lt;/code&gt; on every attempt, and your Edge Function logs show nothing at all.&lt;/p&gt;

&lt;p&gt;That combination is almost always Supabase's built-in JWT check, not your Stripe code. This post covers why it happens, how to turn it off for the webhook function only, and the step you must take right after, because once the function is public, the Stripe signature is the only thing protecting it. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 401 looks like
&lt;/h2&gt;

&lt;p&gt;Open the endpoint in the Stripe Dashboard and look at a failed delivery. If the response body is one of these, the request never reached your handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Missing authorization header"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid Token or Protected Header formatting"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase's troubleshooting guide says both messages "come from the legacy auth verification check", which "runs before your code. When it fails, your function never executes." If the body is a message you wrote yourself (or empty), your own code returned the 401 and you should look there instead.&lt;/p&gt;

&lt;p&gt;Stripe's side agrees: its webhook docs list 4xx responses, including 401 and 403 "when the destination URL has access restrictions", as failed deliveries, and ask that your endpoint be "publicly accessible to the internet".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;Supabase's function configuration docs: "By default, Edge Functions require a valid JWT in the authorization header." Stripe sends a &lt;code&gt;Stripe-Signature&lt;/code&gt; header and a JSON body. It has no Supabase user token, so the platform rejects it with a 401 before &lt;code&gt;Deno.serve&lt;/code&gt; (or your exported &lt;code&gt;fetch&lt;/code&gt;) ever runs.&lt;/p&gt;

&lt;p&gt;Supabase calls this out directly: "Stripe webhooks need to be publicly accessible (Stripe doesn't have your user tokens)."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: turn off JWT verification for the webhook function only
&lt;/h2&gt;

&lt;h3&gt;
  
  
  In &lt;code&gt;config.toml&lt;/code&gt; (sticks across deploys)
&lt;/h3&gt;

&lt;p&gt;This is the exact example in Supabase's function configuration docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# supabase/config.toml&lt;/span&gt;
&lt;span class="c"&gt;# Disables authentication for the Stripe webhook.&lt;/span&gt;
&lt;span class="nn"&gt;[functions.stripe-webhook]&lt;/span&gt;
&lt;span class="py"&gt;verify_jwt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key after &lt;code&gt;functions.&lt;/code&gt; is your function's name. Leave every other function on the default.&lt;/p&gt;

&lt;h3&gt;
  
  
  On the CLI
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;supabase functions deploy&lt;/code&gt; reference lists &lt;code&gt;--no-verify-jwt&lt;/code&gt; ("Disable JWT verification for the Function"), and Supabase's own Stripe example deploys with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase functions deploy stripe-webhook &lt;span class="nt"&gt;--no-verify-jwt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For local testing, the same flag works on &lt;code&gt;serve&lt;/code&gt;, and the example pairs it with &lt;code&gt;stripe listen&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# terminal 1&lt;/span&gt;
supabase functions serve &lt;span class="nt"&gt;--no-verify-jwt&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; ./supabase/.env.local
&lt;span class="c"&gt;# terminal 2&lt;/span&gt;
stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:54321/functions/v1/stripe-webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  In the Dashboard
&lt;/h3&gt;

&lt;p&gt;Supabase's 401 troubleshooting guide also lists a Dashboard route: open the function's detail tab and toggle off JWT verification. If your function gets redeployed by a tool (an AI app builder, a CI job), put the setting in &lt;code&gt;config.toml&lt;/code&gt; as well so a redeploy doesn't quietly turn it back on. Users have reported exactly that in a Supabase CLI issue (linked below), where "Enforce JWT verification" was enabled again after a push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: verify the Stripe signature, the Deno way
&lt;/h2&gt;

&lt;p&gt;Supabase's docs carry a warning with the flag: it "will allow anyone to invoke your Edge Function without a valid JWT." Your webhook URL is now public. If the handler trusts the JSON body, anyone who knows the URL can POST a hand-written &lt;code&gt;checkout.session.completed&lt;/code&gt; and unlock your product.&lt;/p&gt;

&lt;p&gt;Supabase's "Securing Edge Functions" page says the same for external webhooks: providers like Stripe "sign the request body with their own shared secret", so skip the credential check, "then verify the provider's signature inside the handler. Keep verify_jwt = false."&lt;/p&gt;

&lt;p&gt;Two details matter in Deno:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use the raw body.&lt;/strong&gt; Stripe: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." Read it with &lt;code&gt;await req.text()&lt;/code&gt;. Don't call &lt;code&gt;req.json()&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the async verifier with a Web Crypto provider.&lt;/strong&gt; stripe-node's &lt;code&gt;SubtleCryptoProvider&lt;/code&gt; "only supports asynchronous operations". Its synchronous path throws &lt;code&gt;SubtleCryptoProvider cannot be used in a synchronous context.&lt;/code&gt; So use &lt;code&gt;constructEventAsync&lt;/code&gt; with &lt;code&gt;Stripe.createSubtleCryptoProvider()&lt;/code&gt;, not &lt;code&gt;constructEvent&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the shape of Supabase's official Stripe webhook example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/stripe-webhook/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:stripe@^22&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withSupabase&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:@supabase/server@^1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// Needed to use the Web Crypto API in Deno&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cryptoProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSubtleCryptoProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Stripe verifies the request via its signature, so deploy with verify_jwt = false.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;withSupabase&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &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="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;signature&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stripe-Signature&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// raw body, not req.json()&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&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;signature&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;cryptoProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// handle event.type here (e.g. checkout.session.completed)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&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;ok&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on a plain &lt;code&gt;Deno.serve(async (req) =&amp;gt; { ... })&lt;/code&gt; handler instead of the &lt;code&gt;withSupabase&lt;/code&gt; wrapper, the three lines that matter are the same: &lt;code&gt;await req.text()&lt;/code&gt;, &lt;code&gt;constructEventAsync(...)&lt;/code&gt; with &lt;code&gt;cryptoProvider&lt;/code&gt;, and a &lt;code&gt;400&lt;/code&gt; when it throws.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: the right signing secret for the right endpoint
&lt;/h2&gt;

&lt;p&gt;A 401 that turns into a 400 after Fix 1 usually means your code is now running but the signature doesn't match. Check which &lt;code&gt;whsec_&lt;/code&gt; value the function has.&lt;/p&gt;

&lt;p&gt;From Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one." The &lt;code&gt;stripe listen&lt;/code&gt; command prints its own signing secret too ("Your webhook signing secret is ..."), which is for local forwarding, not your deployed endpoint.&lt;/p&gt;

&lt;p&gt;Set the secret for the deployed function with the Supabase CLI (or under Edge Function Secrets in the Dashboard):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase secrets &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whsec_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase's secrets docs note that functions "read a new secret immediately, so you don't need to redeploy." When you go live, swap in the live endpoint's secret. The test one won't verify live events.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check
&lt;/h2&gt;

&lt;p&gt;Two &lt;code&gt;curl&lt;/code&gt; calls tell you most of what you need. Replace the URL with your function's (&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/&amp;lt;name&amp;gt;&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Unsigned POST, no Authorization header (this is what Stripe "looks like" to the platform)&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/stripe-webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;401&lt;/code&gt; with &lt;code&gt;"Missing authorization header"&lt;/code&gt;: JWT verification is still on. Go back to Fix 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;400&lt;/code&gt; (with the official example, stripe-node's message is &lt;code&gt;No stripe-signature header value was provided.&lt;/code&gt;): good. The function is reachable and rejects unsigned events.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;200&lt;/code&gt;: the function is reachable but &lt;strong&gt;accepts unsigned events&lt;/strong&gt;. Go to Fix 2 now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, in the Stripe Dashboard, resend a failed event and confirm the delivery shows a 2xx. Stripe's docs say Dashboard resends work "for up to 15 days after the event creation", and in live mode Stripe itself keeps retrying for up to three days.&lt;/p&gt;

&lt;p&gt;If you'd rather not run the curls by hand, WebhookProof's free checker runs the reachability and unsigned-event checks against your URL with 3 harmless requests: &lt;a href="https://webhookproof.com/guides/supabase-stripe-webhook-lovable-bolt" rel="noopener noreferrer"&gt;Stripe webhooks on Supabase Edge Functions: 401s and signature errors&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;verify_jwt = false&lt;/code&gt; for the webhook function only, in &lt;code&gt;supabase/config.toml&lt;/code&gt; (and/or deployed with &lt;code&gt;--no-verify-jwt&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Handler reads &lt;code&gt;await req.text()&lt;/code&gt; before anything else touches the body&lt;/li&gt;
&lt;li&gt;[ ] Verification uses &lt;code&gt;constructEventAsync&lt;/code&gt; + &lt;code&gt;Stripe.createSubtleCryptoProvider()&lt;/code&gt; and returns 400 on failure&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/code&gt; is the &lt;code&gt;whsec_&lt;/code&gt; for &lt;em&gt;this&lt;/em&gt; endpoint in &lt;em&gt;this&lt;/em&gt; mode (not the &lt;code&gt;stripe listen&lt;/code&gt; one in production)&lt;/li&gt;
&lt;li&gt;[ ] An unsigned &lt;code&gt;curl&lt;/code&gt; gets 400, and a resent Dashboard event gets 2xx&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. or Supabase, Inc. Quotes are from the linked documentation as read on 2026-10-18.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Supabase, Function Configuration (default JWT requirement, &lt;code&gt;verify_jwt = false&lt;/code&gt; example for &lt;code&gt;stripe-webhook&lt;/code&gt;, &lt;code&gt;--no-verify-jwt&lt;/code&gt; caution): &lt;a href="https://supabase.com/docs/guides/functions/function-configuration" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/function-configuration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Edge Function 401 error response (error bodies, "runs before your code", Dashboard / CLI / Management API fixes): &lt;a href="https://supabase.com/docs/guides/troubleshooting/edge-function-401-error-response" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/troubleshooting/edge-function-401-error-response&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Securing Edge Functions (external webhooks: &lt;code&gt;auth: 'none'&lt;/code&gt;, verify signature, keep &lt;code&gt;verify_jwt = false&lt;/code&gt;): &lt;a href="https://supabase.com/docs/guides/functions/auth" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/auth&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Handling Stripe Webhooks example: &lt;a href="https://supabase.com/docs/guides/functions/examples/stripe-webhooks" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/examples/stripe-webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase example source (&lt;code&gt;constructEventAsync&lt;/code&gt;, &lt;code&gt;createSubtleCryptoProvider&lt;/code&gt;, &lt;code&gt;req.text()&lt;/code&gt;): &lt;a href="https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts" rel="noopener noreferrer"&gt;https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase example README (&lt;code&gt;serve --no-verify-jwt&lt;/code&gt;, &lt;code&gt;deploy --no-verify-jwt&lt;/code&gt;, &lt;code&gt;stripe listen&lt;/code&gt;): &lt;a href="https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/README.md" rel="noopener noreferrer"&gt;https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/README.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase CLI reference, &lt;code&gt;supabase functions deploy&lt;/code&gt; (&lt;code&gt;--no-verify-jwt&lt;/code&gt; flag): &lt;a href="https://supabase.com/docs/reference/cli/supabase-functions-deploy" rel="noopener noreferrer"&gt;https://supabase.com/docs/reference/cli/supabase-functions-deploy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase, Environment variables / secrets (&lt;code&gt;supabase secrets set&lt;/code&gt;, no redeploy needed): &lt;a href="https://supabase.com/docs/guides/functions/secrets" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/functions/secrets&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase CLI issue #3397 (users reporting &lt;code&gt;verify_jwt = false&lt;/code&gt; not applied / JWT enforcement re-enabled after a push): &lt;a href="https://github.com/supabase/cli/issues/3397" rel="noopener noreferrer"&gt;https://github.com/supabase/cli/issues/3397&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Receive Stripe events in your webhook endpoint (raw body, per-endpoint and per-mode secrets, 4xx delivery errors, retries, resend window): &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;stripe-node, &lt;code&gt;SubtleCryptoProvider&lt;/code&gt; source (async-only): &lt;a href="https://github.com/stripe/stripe-node/blob/master/src/crypto/SubtleCryptoProvider.ts" rel="noopener noreferrer"&gt;https://github.com/stripe/stripe-node/blob/master/src/crypto/SubtleCryptoProvider.ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;stripe-node, &lt;code&gt;Webhooks.ts&lt;/code&gt; source (error messages): &lt;a href="https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts" rel="noopener noreferrer"&gt;https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>supabase</category>
      <category>webhooks</category>
      <category>deno</category>
    </item>
    <item>
      <title>Customer paid but didn't get access? 4 reasons your Stripe webhook is silently failing</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Sun, 18 Oct 2026 23:38:57 +0000</pubDate>
      <link>https://dev.to/webhookproof/customer-paid-but-didnt-get-access-4-reasons-your-stripe-webhook-is-silently-failing-2jl5</link>
      <guid>https://dev.to/webhookproof/customer-paid-but-didnt-get-access-4-reasons-your-stripe-webhook-is-silently-failing-2jl5</guid>
      <description>&lt;p&gt;A customer emails you: "I paid, but I still can't log in." Stripe shows a successful payment. Your app shows nothing. No exception, no log line. The webhook that should have unlocked their account either never arrived or arrived and was handled wrong.&lt;/p&gt;

&lt;p&gt;Below are the four most common causes, how to confirm each one, and a short fix for Node/Next.js and Supabase. Every claim links to the docs it comes from.&lt;/p&gt;

&lt;p&gt;If you'd rather check first and read later, &lt;a href="https://webhookproof.com" rel="noopener noreferrer"&gt;WebhookProof&lt;/a&gt; is a free tool that sends your webhook URL 3 harmless requests and tells you whether Stripe can reach it and whether it rejects unsigned events. (Not affiliated with Stripe, Inc.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Before anything: read the delivery log
&lt;/h2&gt;

&lt;p&gt;In the Stripe Dashboard, open your webhook endpoint and look at its event deliveries. Each attempt records the HTTP status your server returned, and that status usually points straight at one of the causes below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status in the log&lt;/th&gt;
&lt;th&gt;Most likely cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;301 / 302 / 307 / 308&lt;/td&gt;
&lt;td&gt;#1 Redirect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401 / 403&lt;/td&gt;
&lt;td&gt;#2 Auth wall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200, but anyone can trigger it&lt;/td&gt;
&lt;td&gt;#3 No signature verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 "No signatures found matching the expected signature"&lt;/td&gt;
&lt;td&gt;#4 Body was parsed before verification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stripe documents these delivery errors on its &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Receive events / webhooks page&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The endpoint redirects (www, https, trailing slash)
&lt;/h2&gt;

&lt;p&gt;Stripe does not follow redirects on webhook deliveries. From the &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We consider redirect responses to webhook requests as failures. Set the webhook endpoint destination to the URL resolved by the redirect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Common sources of a surprise redirect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apex vs www.&lt;/strong&gt; Vercel lets you redirect between &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;www.example.com&lt;/code&gt; (&lt;a href="https://vercel.com/docs/domains/working-with-domains/deploying-and-redirecting" rel="noopener noreferrer"&gt;Vercel docs&lt;/a&gt;). If Stripe is pointed at the non-canonical host, every event bounces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slash.&lt;/strong&gt; "By default Next.js will redirect URLs with trailing slashes to their counterpart without a trailing slash" (&lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash" rel="noopener noreferrer"&gt;Next.js &lt;code&gt;trailingSlash&lt;/code&gt; docs&lt;/a&gt;). So &lt;code&gt;/api/webhooks/stripe/&lt;/code&gt; gets a redirect. With &lt;code&gt;trailingSlash: true&lt;/code&gt; it's the reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect rules.&lt;/strong&gt; In Next.js &lt;code&gt;redirects()&lt;/code&gt;, &lt;code&gt;permanent: true&lt;/code&gt; returns 308 and &lt;code&gt;permanent: false&lt;/code&gt; returns 307 (&lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/redirects" rel="noopener noreferrer"&gt;Next.js redirects docs&lt;/a&gt;). A broad rule can catch your webhook path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;http to https.&lt;/strong&gt; Always register the https URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Confirm it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://example.com/api/webhooks/stripe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response is a 3xx with a &lt;code&gt;location:&lt;/code&gt; header, that header is your real URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; paste the exact final URL (host, scheme, slash and all) into the endpoint settings in the Dashboard, then resend a failed event from the Dashboard. Don't try to "fix" it by adding more redirects.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. An auth wall is asking Stripe to log in
&lt;/h2&gt;

&lt;p&gt;Stripe can't send a session cookie, a Supabase JWT or a Vercel login. If anything in front of your handler requires one, Stripe gets a 401/403 and your code never runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase Edge Functions: JWT verification
&lt;/h3&gt;

&lt;p&gt;Edge Functions check for a Supabase JWT by default. Supabase's own &lt;a href="https://supabase.com/docs/guides/functions/examples/stripe-webhooks" rel="noopener noreferrer"&gt;Stripe webhook example&lt;/a&gt; says: "Stripe verifies the request via its signature, so deploy with verify_jwt = false." The &lt;a href="https://supabase.com/docs/guides/functions/function-configuration" rel="noopener noreferrer"&gt;function configuration docs&lt;/a&gt; show how to do it for just that function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# supabase/config.toml&lt;/span&gt;
&lt;span class="nn"&gt;[functions.stripe-webhook]&lt;/span&gt;
&lt;span class="py"&gt;verify_jwt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the function publicly callable, which is exactly why cause #3 (signature verification) is not optional.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vercel Deployment Protection
&lt;/h3&gt;

&lt;p&gt;If &lt;a href="https://vercel.com/docs/deployment-protection" rel="noopener noreferrer"&gt;Deployment Protection&lt;/a&gt; covers the URL Stripe calls (often a preview or &lt;code&gt;*.vercel.app&lt;/code&gt; URL), Stripe hits a login wall. The simplest fix is to point Stripe at your production domain. If you really need a protected deployment to receive webhooks, Vercel's &lt;a href="https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation" rel="noopener noreferrer"&gt;Protection Bypass for Automation&lt;/a&gt; supports an &lt;code&gt;x-vercel-protection-bypass&lt;/code&gt; query parameter, and the docs name Stripe as a case for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-deployment.vercel.app/api/webhooks/stripe?x-vercel-protection-bypass=YOUR_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Your own middleware / proxy
&lt;/h3&gt;

&lt;p&gt;Auth middleware that runs on every route will run on your webhook route too. Without a &lt;code&gt;matcher&lt;/code&gt;, Next.js "Proxy runs on every request" (&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy" rel="noopener noreferrer"&gt;Next.js proxy docs&lt;/a&gt;; &lt;code&gt;middleware&lt;/code&gt; has been renamed to &lt;code&gt;proxy&lt;/code&gt;, same idea). Exclude the webhook path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts (or middleware.ts on older Next.js)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api/webhooks/stripe|_next/static|_next/image|favicon.ico).*)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea in Express: register the webhook route before your auth middleware, or skip that path in it.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. No signature verification: anyone can fake a payment
&lt;/h2&gt;

&lt;p&gt;This one doesn't show up as a failure at all, which is what makes it dangerous. If your handler does something like this:&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;// Don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="nf"&gt;grantAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then anyone who knows the URL can POST a hand-written &lt;code&gt;checkout.session.completed&lt;/code&gt; and unlock your product. Webhook paths are predictable (&lt;code&gt;/api/webhooks/stripe&lt;/code&gt; is in half the tutorials out there), so treat the URL as public.&lt;/p&gt;

&lt;p&gt;Stripe signs every event with the endpoint's secret in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header and tells you to verify it with the official library (&lt;a href="https://docs.stripe.com/webhooks#verify-events" rel="noopener noreferrer"&gt;Stripe docs: verify events&lt;/a&gt;). The signed payload includes a timestamp, and "our libraries have a default tolerance of 5 minutes between the timestamp and the current time," which limits replays (&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Two extra guards worth adding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-check with the API before granting access.&lt;/strong&gt; Stripe's &lt;a href="https://docs.stripe.com/checkout/fulfillment" rel="noopener noreferrer"&gt;fulfillment guide&lt;/a&gt; retrieves the Checkout Session and checks &lt;code&gt;payment_status&lt;/code&gt; isn't &lt;code&gt;unpaid&lt;/code&gt;. A forged event can't fake the API's answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make fulfillment idempotent.&lt;/strong&gt; The same guide warns your fulfill function "might be called multiple times, possibly concurrently, for the same Checkout Session."
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&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;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unpaid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Use a unique constraint on session.id so a second call is a no-op&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grantAccessOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also watch the secret itself: "if you use the same endpoint for both test and live API keys, the secret is different for each one" (&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks docs&lt;/a&gt;). Shipping to production with the test &lt;code&gt;whsec_&lt;/code&gt; (or the &lt;code&gt;stripe listen&lt;/code&gt; secret) makes every live event fail verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm it:&lt;/strong&gt; send an unsigned request to your own endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/api/webhooks/stripe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correctly protected endpoint answers &lt;strong&gt;400&lt;/strong&gt;. A &lt;strong&gt;200&lt;/strong&gt; means it accepts unsigned events.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Your framework parsed the body before you verified it
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Parsing JSON and stringifying it again changes whitespace and key order, so the signature no longer matches. The Node library then throws "No signatures found matching the expected signature for payload." The tempting "fix" is deleting the check, which takes you straight back to cause #3.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js App Router
&lt;/h3&gt;

&lt;p&gt;Read the body as text and pass that exact string in. Don't call &lt;code&gt;req.json()&lt;/code&gt; first. &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;Route handlers&lt;/a&gt; give you a standard &lt;code&gt;Request&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&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;Request&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                      &lt;span class="c1"&gt;// raw body&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&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="na"&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="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.async_payment_succeeded&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two event types are the ones Stripe's &lt;a href="https://docs.stripe.com/checkout/fulfillment" rel="noopener noreferrer"&gt;fulfillment guide&lt;/a&gt; handles for Checkout. The second one covers delayed payment methods.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js Pages Router
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/pages/building-your-application/routing/api-routes" rel="noopener noreferrer"&gt;API routes docs&lt;/a&gt; list webhook signature checks as the reason to turn off the body parser:&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;// pages/api/webhooks/stripe.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bodyParser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rawBody&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&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;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rawBody&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="k"&gt;try&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...handle 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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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="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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Express
&lt;/h3&gt;

&lt;p&gt;Give the webhook route &lt;a href="https://expressjs.com/en/api.html#express.raw" rel="noopener noreferrer"&gt;&lt;code&gt;express.raw()&lt;/code&gt;&lt;/a&gt; and register it &lt;strong&gt;before&lt;/strong&gt; any global &lt;code&gt;express.json()&lt;/code&gt;:&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="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="s1"&gt;/api/webhooks/stripe&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;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="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;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;body&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&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 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;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;use&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="c1"&gt;// everything else&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Supabase Edge Functions (Deno)
&lt;/h3&gt;

&lt;p&gt;Deno needs the async verifier and a Web Crypto provider, as in &lt;a href="https://supabase.com/docs/guides/functions/examples/stripe-webhooks" rel="noopener noreferrer"&gt;Supabase's Stripe example&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:stripe&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;cryptoProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSubtleCryptoProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="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;signature&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stripe-Signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// raw body&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&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;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cryptoProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...handle event&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The synchronous &lt;code&gt;constructEvent&lt;/code&gt; doesn't work there, and neither does parsing JSON first.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: don't rely on the success page
&lt;/h2&gt;

&lt;p&gt;Even with a perfect webhook, fulfilling only on the redirect page will eventually miss someone. Stripe says: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page" (&lt;a href="https://docs.stripe.com/checkout/fulfillment" rel="noopener noreferrer"&gt;Stripe fulfillment docs&lt;/a&gt;). Call the same idempotent &lt;code&gt;fulfill()&lt;/code&gt; from both the webhook and the success page.&lt;/p&gt;

&lt;p&gt;And once it's fixed, you don't have to wait for retries. In live mode Stripe retries failed deliveries for up to three days (&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe webhooks docs&lt;/a&gt;), and you can resend a failed event from the Dashboard right away. For the customers who already paid, run &lt;code&gt;fulfill()&lt;/code&gt; for their Checkout Session IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The URL in Stripe is the final URL: no 3xx from &lt;code&gt;curl -i -X POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] No JWT, login, deployment protection or auth middleware in front of the route&lt;/li&gt;
&lt;li&gt;[ ] An unsigned POST gets a 400, not a 200&lt;/li&gt;
&lt;li&gt;[ ] Verification uses the raw body and the right &lt;code&gt;whsec_&lt;/code&gt; for the mode&lt;/li&gt;
&lt;li&gt;[ ] Fulfillment re-checks the session via the API and is safe to run twice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a second opinion on the first three, the free check at &lt;a href="https://webhookproof.com" rel="noopener noreferrer"&gt;webhookproof.com&lt;/a&gt; runs them against your URL with 3 harmless requests and shows the results. WebhookProof is an independent tool, not affiliated with Stripe, Inc.&lt;/p&gt;

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