<?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>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>
