VibeFix › Guides

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.

Updated October 23, 2026 · About 9 minutes to read

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:

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:

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

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 })
  }
})

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:

6. Subscribe to the right events, in the right mode and format

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.

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

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.

Get a free check See pricing

Already know you need help? Quick Fix, $49 (one bug, 24h) · Standard Rescue, $129 · Deploy-Ready, from $199

Get 3 fixes for common vibe-coding bugs each week

One short, free email a week with 3 real fixes for Lovable, Bolt, Cursor, Replit and v0 apps.

By signing up, you agree to get one weekly email from VibeFix at this address. Unsubscribe anytime with the link in every email. We never sell or share your email.