Stripe payment works, but your app never finds out (webhook failing)
One error, one plain-English fix you can paste into your AI tool.
Last checked against official docs: 19 Oct 2026. Educational information; test in Stripe test mode first.
Symptom
- Customer pays, Stripe shows the payment, but your app doesn't unlock the plan / mark the order paid.
- Logs show
Webhook signature verification failed/No signatures found matching the expected signature for payload. - In Stripe the webhook delivery shows Failed with 400, 401/403, 404, 3xx, 500 or "Timed out".
Why it happens (plain words)
After a payment, Stripe sends a message (a webhook) to a URL on your server. Your server must (1) prove the message really came from Stripe by checking its signature, then (2) reply "200 OK" quickly. It breaks when:
- Wrong secret. Each endpoint has its own
whsec_...signing secret. The one printed bystripe listen(local testing) is different from the one for your Dashboard endpoint (production). Stripe calls this "the most common cause." - The message got changed before checking. The signature only matches the exact raw text Stripe sent. Many frameworks automatically turn it into JSON first (e.g.
express.json()running before your webhook route), which breaks the check. - Wrong URL / redirect / login wall. Stripe treats redirects (3xx) as failures. A 404 or a route that requires login (401/403) also fails.
- Too slow. Doing heavy work before replying causes timeouts.
- Test vs live mismatch. Test-mode secret with a live-mode endpoint, or the reverse.
How to confirm
- Stripe Dashboard → Workbench → Webhooks → your endpoint → Event deliveries. Note the status code of the failures.
- Check your server/function logs for the exact error text.
- Print (just the first few characters of) the
whsec_your code is using, then compare with "Reveal secret" on that endpoint. Don't log the full secret.
Step-by-step fix
- Use the right signing secret for the right environment: Dashboard endpoint secret in production;
stripe listensecret locally. Store it as a server env var (e.g.STRIPE_WEBHOOK_SECRET). - Pass the raw body to
stripe.webhooks.constructEvent(rawBody, signatureHeader, secret):- Express: define the webhook route with
express.raw({ type: 'application/json' }), and put the webhook route beforeapp.use(express.json()). - Next.js App Router: read
await request.text(). - Next.js Pages Router: disable
bodyParserand read the raw buffer. - Supabase Edge Functions (Deno): read
await req.text()and use the async verify method (constructEventAsync) the Stripe Node SDK provides for non-Node runtimes.
- Express: define the webhook route with
- Point the endpoint at the final URL (no redirect: watch
http→httpsandwwwvs no-www), publicly reachable, accepting POST, with no login check on that route (the signature check is the security). - Reply 200 fast. Verify, record the event, return 200, then do slow work afterwards or in a queue.
- Handle duplicates. Stripe can send the same event more than once and retries failures for up to three days. Store processed
event.ids and skip repeats. - Only subscribe to the event types you use (e.g.
checkout.session.completed). - Recover missed events: resend from the Dashboard, or list them via the Events API (last 30 days).
Copy-paste prompt for your AI tool
My Stripe webhook is failing with [paste error / HTTP status from Stripe Dashboard]. Fix the webhook handler:
1. Show me which file handles the Stripe webhook and which runtime it runs on (Express, Next.js App/Pages Router, Supabase Edge Function, etc.).
2. Make sure the RAW request body (unparsed) is passed to Stripe's signature verification (constructEvent, or constructEventAsync on Deno/edge), together with the Stripe-Signature header and the signing secret read from a server-only env var STRIPE_WEBHOOK_SECRET. No JSON parsing middleware may run before it.
3. The route must accept POST without requiring user login and must not redirect.
4. Return 200 immediately after verifying and recording the event; do slow work after.
5. Make it idempotent: store processed event.id values and skip duplicates.
6. Return 400 when signature verification fails. Never log the secret or full payloads with personal data.
Explain the changes in plain English and tell me exactly which signing secret (CLI vs Dashboard, test vs live) I should be using where.
How to verify it's fixed
- Locally:
stripe listen --forward-to localhost:<port>/<your-webhook-path>andstripe trigger checkout.session.completed→ handler logs success. - In production (test mode): make a test payment → Event deliveries shows 200, and your app updates.
- Resend the same event from the Dashboard → your app doesn't double-process it.
Safety notes
- Never skip signature verification to "make it work". Without it anyone can send fake "payment succeeded" messages to your app.
- Don't decide what to unlock based on prices sent from the browser. Read them from the verified Stripe event or the Stripe API.
- Keep
sk_...andwhsec_...server-side only.
Sources
- Stripe: Manage webhook endpoints / resolve signature verification errors, https://docs.stripe.com/webhooks/signature
- Stripe: Set up events / webhooks, https://docs.stripe.com/webhooks
- Stripe: Keys best practices, https://docs.stripe.com/keys-best-practices
