Stripe webhook not working in production? Fix "No signatures found matching the expected signature" and missing events
Checkout works and the payment shows up in Stripe, but your app never finds out: the order stays "pending", the user isn't upgraded, or the logs say "No signatures found matching the expected signature for payload". It worked with stripe listen on localhost and broke after deploy. In almost every Lovable, Bolt and Cursor app we see, it's one of four things: the wrong signing secret, a parsed instead of raw body, a URL that redirects or needs a login, or a handler that is too slow or crashes.
- Find the failing delivery in Stripe first
- Use the signing secret for this exact endpoint
- Verify the raw body, not parsed JSON
- Supabase Edge Functions (Lovable): async verification and no JWT check
- Make the URL reachable: no redirect, no login, a real POST route
- Subscribe to the right events, in the right mode and format
- Reply 2xx fast, and survive retries and duplicates
- Test locally, then test the deployed endpoint
- Lovable, Bolt and Cursor: what to check and what to ask
- FAQ
1. Find the failing delivery in Stripe first
Don't guess from your code. Stripe records every attempt: open Workbench → Webhooks (or the Webhooks tab in the Dashboard), click your endpoint and open Event deliveries. Each attempt shows the HTTP status your server returned and the response body. The status tells you where to look:
- 400 with
Webhook Error: No signatures found…: your handler ran and rejected the signature. Go to steps 2 and 3. - 3xx (301, 307, 308): Stripe treats any redirect as a failure and doesn't follow it. See step 5.
- 401 / 403: something in front of the handler wants a login (auth middleware, Vercel Deployment Protection, Supabase JWT check). Steps 4 and 5.
- 404 / 405: the URL doesn't match a deployed route, or the route doesn't accept POST. Step 5.
- 500, 502, 504 or "timed out": the handler crashed or did too much work before replying. Check your host's function logs (Vercel: project → Logs; Netlify: Logs → Functions; Supabase: Edge Functions → Logs). Step 7.
- No attempts at all: the endpoint isn't subscribed to that event, or it's in the other mode (sandbox vs live). Step 6.
2. Use the signing secret for this exact endpoint
Stripe's troubleshooting page says the most common cause of signature errors is the wrong endpoint secret. Every endpoint has its own whsec_… secret, and they are not interchangeable:
- The secret printed by
stripe listenonly works for events forwarded by the CLI. Your deployed endpoint has a different secret: open it in Workbench and click Reveal secret. - Sandbox (test) and live mode endpoints are separate, each with its own secret. When you switch to live keys, you also need a live endpoint and its secret.
- If you deleted and recreated the endpoint, or rolled the secret, the old value in your env vars is dead.
Put the secret in a server-only variable such as STRIPE_WEBHOOK_SECRET on the host (never with a VITE_ or NEXT_PUBLIC_ prefix), then redeploy, since Vercel and Netlify only apply env changes to new deployments (more in Environment variable undefined after deploy?). Paste it without quotes or a trailing newline: recent versions of stripe-node add a note to the error when the secret contains whitespace. To confirm what the code is using without leaking it, log only the first characters: console.log(process.env.STRIPE_WEBHOOK_SECRET?.slice(0, 10)).
3. Verify the raw body, not parsed JSON
The signature is computed over the exact bytes Stripe sent. If your framework parses the body into JSON first, even re-serializing it identically, verification fails. This is the classic AI-generated bug: the handler starts with await req.json() or sits behind express.json(). Use the raw body for each stack:
// Next.js App Router: app/api/webhooks/route.ts
export async function POST(req: Request) {
const body = await req.text() // raw body, NOT req.json()
const sig = req.headers.get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
}
// ...handle event...
return new Response('ok', { status: 200 })
}
// Express: the webhook route must come BEFORE app.use(express.json())
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET)
res.sendStatus(200)
})
app.use(express.json()) // for every other route
- Next.js Pages Router (
pages/api/…): disable the built-in parser withexport const config = { api: { bodyParser: false } }and read the raw buffer (Stripe's docs usebuffer(req)frommicro). - If a proxy or tool (Zapier, n8n, a "webhook relay") forwards the request, it must pass the body through byte-for-byte, including whitespace and line endings.
- Read the header as
stripe-signature; its value looks liket=…,v1=…. If it's empty, you'll getNo stripe-signature header value was provided.
4. Supabase Edge Functions (Lovable): async verification and no JWT check
Lovable and many Bolt apps handle Stripe in a Supabase Edge Function. Deno has no Node crypto, so the synchronous constructEvent fails with SubtleCryptoProvider cannot be used in a synchronous context. Use `await constructEventAsync(...)` instead of `constructEvent(...)`. this is the pattern from Supabase's official Stripe webhook example (simplified):
import Stripe from 'npm:stripe@^22'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature')
const body = await req.text() // raw body
try {
const event = await stripe.webhooks.constructEventAsync(
body, signature!, Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!, undefined, cryptoProvider)
// ...handle event...
return Response.json({ ok: true })
} catch (err) {
return new Response((err as Error).message, { status: 400 })
}
})
- Turn off the JWT check for this one function. Stripe can't send a Supabase JWT, so with the default check every delivery gets a 401 before your code runs. In
supabase/config.toml, add a[functions.stripe-webhook]section withverify_jwt = false(use your function's name), or deploy withsupabase functions deploy stripe-webhook --no-verify-jwt. The Stripe signature is your authentication, so keep the verification. - Set both secrets for the function with
supabase secrets set STRIPE_API_KEY=… STRIPE_WEBHOOK_SIGNING_SECRET=…(or in the dashboard under Edge Functions → Secrets) and read them withDeno.env.get. - The endpoint URL is
https://<project-ref>.supabase.co/functions/v1/<function-name>. Browser-side CORS errors from the same functions are a different problem: see Supabase Edge Function CORS error?
5. Make the URL reachable: no redirect, no login, a real POST route
Stripe's docs say it considers redirect responses to webhook requests as failures, so the endpoint URL must be the final URL. Common traps:
- www vs non-www, trailing slash, http vs https. If your site redirects
example.comtowww.example.com(or adds/removes a trailing slash), register exactly the URL that answers with no redirect. Test it:curl -i -X POST https://www.example.com/api/webhooksshould give a 400 from your handler (no signature), not a 3xx. - Auth middleware. Next.js
middleware.tsfrom Clerk, NextAuth/Auth.js or Supabase often redirects every unauthenticated request to/login, including Stripe's. Exclude the webhook path from the matcher or mark it public. - Vercel Deployment Protection. With Standard Protection, preview and generated deployment URLs (
…vercel.app) require a Vercel login, so Stripe gets a 401. Point the live endpoint at your production domain. - No server route. A plain Vite/React app (typical Lovable or Bolt export) has no backend, so
/api/webhookis just the SPA'sindex.htmlor a 404/405. The handler has to be a serverless function (Vercelapi/, Netlify Functions) or a Supabase Edge Function. - Path typos.
/api/webhookvs/api/webhooks, or a route file in the wrong folder, gives a 404.
6. Subscribe to the right events, in the right mode and format
- Selected events. An endpoint only receives the event types you picked. Checkout and Payment Links flows usually need
checkout.session.completed(andcheckout.session.async_payment_succeededfor delayed methods); subscriptions also needcustomer.subscription.updated,customer.subscription.deletedandinvoice.paid/invoice.payment_failed. Edit the endpoint and add what your handler switches on. - Sandbox vs live. Payments made with live keys only go to live endpoints. If you only created the endpoint in a sandbox, live payments never reach your app, and there will be no delivery attempts at all.
- Thin vs snapshot payloads. When you create an event destination, Stripe now asks for a payload format: Thin (recommended) or Snapshot. Classic handlers that read
event.data.objectexpect snapshot events. Pass a thin event toconstructEventand stripe-node throwsYou passed a thin event notification to a function that expects a webhook.Either use a Snapshot destination for that code or switch the handler tostripe.parseEventNotification(). - Connected accounts. If you use Stripe Connect, events from connected accounts go to a destination set to listen to connected accounts, not "Your account".
7. Reply 2xx fast, and survive retries and duplicates
Stripe expects a quick 2xx before any slow work; a timeout counts as a failed delivery even if your code finishes later. In live mode Stripe retries failed deliveries for up to three days with exponential backoff (sandbox: three times over a few hours), so a broken endpoint also means a pile of retries once it's fixed.
- Verify, record, reply. Verify the signature, save the event (or enqueue it), return 200, then do emails, PDFs and third-party calls afterwards or in a background job.
- Deduplicate. The same event can arrive more than once. Store
event.idand skip events you've already processed, still returning 200 so Stripe stops retrying. - Don't rely on order. Stripe doesn't guarantee events arrive in the order they were created, so
invoice.paidcan land beforecustomer.subscription.created. Fetch the latest object from the API if you need current state. Timestamp outside the tolerance zonemeans the signed timestamp is older than the 5-minute default tolerance, usually a wrong server clock or replaying an old request. Don't "fix" it with a tolerance of 0: that disables the replay check.- Catch up on missed events. After fixing, click Resend on a failed event in the Dashboard (up to 15 days back) or run
stripe events resend evt_… --webhook-endpoint=we_…(up to 30 days).
8. Test locally, then test the deployed endpoint
# 1. Local: forward events and use the whsec_ that this command prints
stripe listen --forward-to localhost:3000/api/webhooks
stripe trigger checkout.session.completed
# 2. Deployed: should return 400 from YOUR handler (no signature), not 3xx/401/404
curl -i -X POST https://yourdomain.com/api/webhooks
Then create a sandbox endpoint pointing at the deployed URL with its own secret, run a test checkout, and check Event deliveries shows 200. Only after that, create the live endpoint, put its secret in the Production env var, redeploy, and watch the first real payment's delivery. Return a useful error body on 400 (as in the examples above): Stripe shows it in the delivery details, which makes the next failure a 30-second fix.
9. Lovable, Bolt and Cursor: what to check and what to ask
- Lovable: Stripe logic usually lives in
supabase/functions/. Check the webhook function usesreq.text()+constructEventAsync, hasverify_jwt = false, and that the signing secret is set in the Supabase project's Edge Function secrets. More publish problems: Lovable app broken after publishing? - Bolt: a Bolt app exported to Netlify has no server unless you add a Netlify Function or a Supabase Edge Function for the webhook. See Bolt.new app not working on Netlify?
- Cursor / Claude Code (Next.js on Vercel): look for
req.json()beforeconstructEvent, a middleware matcher that covers/api, and the secret only being set for Preview. See App works locally but not on Vercel?
A prompt that works: "Rewrite the Stripe webhook handler to verify the signature against the raw request body (req.text(), or constructEventAsync with createSubtleCryptoProvider in Deno), read STRIPE_WEBHOOK_SECRET from a server-only env var, return 400 with the error message on failure, return 200 immediately after verifying, skip already-processed event IDs, and make sure no middleware or auth check runs on this route."
Still not getting a 200? Send us your live URL and a screenshot of the failed delivery through the free check form or by email to fix@getvibefix.com. We'll tell you exactly what's wrong for free, before you pay anything.
FAQ
Why does my Stripe webhook say "No signatures found matching the expected signature for payload"?
Either the signing secret doesn't belong to this endpoint (the stripe listen secret, a sandbox secret in production, or an old secret after recreating the endpoint), or the body was parsed or modified before verification. Use the whsec_ secret shown under Reveal secret for this exact endpoint, pass the raw body (req.text() in Next.js App Router, express.raw in Express), and redeploy after changing the env var.
Why does my Stripe webhook work locally with stripe listen but not in production?
stripe listen prints its own signing secret that only works for CLI-forwarded events. Your deployed endpoint has a different secret, which must be set as a server env var on the host (then redeploy). Also check the production URL doesn't redirect, isn't behind auth middleware or Vercel Deployment Protection, and that the endpoint is in the same mode (sandbox or live) as the payments.
Does Stripe follow redirects on webhook URLs?
No. Stripe treats any 3xx response to a webhook delivery as a failure. Register the final URL exactly as it answers, including www or non-www, https and the trailing slash, so a POST to it reaches your handler without a redirect.
How do I verify Stripe webhooks in a Supabase Edge Function?
Read the body with await req.text(), create a crypto provider with Stripe.createSubtleCryptoProvider(), and call await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, cryptoProvider). The synchronous constructEvent doesn't work in Deno. Deploy the function with verify_jwt = false, because Stripe can't send a Supabase JWT, and keep the signature check as the authentication.
How do I resend failed Stripe webhook events after fixing my endpoint?
In live mode Stripe retries automatically for up to three days. You can also click Resend on the event in the Dashboard (up to 15 days after it was created) or run stripe events resend evt_ID --webhook-endpoint=we_ID with the Stripe CLI (up to 30 days). Make your handler skip event IDs it has already processed.
Still stuck? We'll fix it at a flat price.
VibeFix fixes broken Lovable, Bolt, Cursor, Replit and v0 apps. Request a free check first, and we'll tell you what's wrong and which plan fits before you pay. If we can't fix it, you get a full refund.
Already know you need help? Quick Fix, $49 (one bug, 24h) · Standard Rescue, $129 · Deploy-Ready, from $199