Everything passed in test mode. You switched to live keys, made a real purchase, and the webhook started failing. Stripe's delivery log shows 400 on every event, and your logs say:
No signatures found matching the expected signature for payload.
Your code didn't change. The secret did, or rather it should have and didn't. This post covers why each Stripe endpoint and mode has its own signing secret, the three secrets that usually get mixed up, and how to swap the right one in without dropping events. Every claim links to the doc it comes from (see Sources at the end).
One endpoint, one mode, one secret
Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one you want to verify signatures on."
Stripe's API keys page also notes that webhook secrets aren't API keys: "Webhook signing secrets aren't API keys—they're per-webhook secrets that your webhook receiver uses to authenticate that webhooks actually came from Stripe."
So changing sk_test_ to sk_live_ doesn't touch your whsec_ value at all. Stripe's go-live steps list it as its own step: "If you use webhooks, update each webhook endpoint's URL and copy the new signing secret from the Webhooks section of the Dashboard."
Why the error looks like this
Stripe signs every event with an HMAC-SHA256 of the timestamp and the raw body, keyed with that endpoint's secret. Your library recomputes the HMAC with the secret you give it and compares the two. With the wrong secret the HMACs can never match, so the library raises the generic error above.
Stripe's troubleshooting page says that if you see it, "at least one of the three parameters you passed is incorrect": the raw body, the Stripe-Signature header, or the endpoint secret. It also names the usual suspect: "The most common cause is using the wrong endpoint secret."
The three secrets that get mixed up
All three start with whsec_, which is why it's easy to mix them up.
-
The
stripe listensecret. The CLI prints its own: "Ready! Your webhook signing secret is '...'". Stripe's troubleshooting page: "The secret for a CLI-forwarded endpoint is different from the secret for a Dashboard-managed endpoint—don't mix them up." It belongs in.env.local, not in production. - The test-mode (sandbox) endpoint secret. It verifies sandbox events only. Stripe: "Each mode has its own set of API keys, and objects in one mode aren't accessible to the other."
- The live endpoint secret. Shown on the live-mode endpoint in Workbench → Webhooks → Reveal secret. This is the only one that verifies live events.
A fourth situation catches teams with more than one endpoint, such as staging and production, or two separate handlers. Each endpoint has its own secret, so copying one endpoint's secret into the other endpoint's environment fails the same way.
How to tell which one you have
Don't print the secret to your logs. Compare it by eye instead:
- In Stripe, switch to live mode, open Workbench → Webhooks, select the endpoint whose URL matches your production handler, and click Reveal secret.
- In your host's environment settings, open the production value of
STRIPE_WEBHOOK_SECRET(or whatever your code reads) and compare the first and last few characters. - Check that the code reads the variable you think it does. A fallback like
process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_...'holding a hardcoded test secret causes the same failure. Stripe's key guidance applies here too: "Don't put keys in source code or configuration files checked into version control."
Some quick tells in the Stripe delivery log:
- Live events fail with 400, test events pass → live endpoint, test secret.
- Nothing fails in Stripe, but production events never show up → there may be no live endpoint yet. Stripe keeps sandbox and live objects apart, so an endpoint created in a sandbox won't receive live events. Create one in live mode.
- Failures began right after "Roll secret" → the old secret expired before your server got the new one (see below).
Swapping the secret without losing events
Update the variable, then make sure the running code sees it. On Vercel: "Any change you make to environment variables are not applied to previous deployments, they only apply to new deployments." Redeploy production after you change the variable. On Supabase Edge Functions, supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_... takes effect without a redeploy, per Supabase's secrets docs.
When rolling a secret, use the overlap window. Stripe lets you "delay its expiration for up to 24 hours to allow yourself time to update the verification code on your server. During this time, multiple secrets are active for the endpoint. Stripe generates one signature per secret until expiration." Deploy the new secret within that window and nothing fails.
Recover what failed. In live mode Stripe retries failed deliveries "for up to three days with an exponential back off". Anything older, or anything you want handled now, can be resent from the Dashboard "for up to 15 days after the event creation", or with stripe events resend for up to 30 days. Stripe makes "a new signature and timestamp for the new delivery attempt", so resent events verify against the new secret.
Don't fix it by turning verification off
When this error shows up on launch day, it's tempting to comment out the signature check "for now". Stripe's docs explain the cost: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records." Keep returning 400 when verification fails, as Stripe's own examples do, and fix the secret.
Go-live checklist
- [ ] A live-mode endpoint exists for the production URL, subscribed to the events you handle
- [ ] Production
STRIPE_WEBHOOK_SECRET= that live endpoint'swhsec_(not the sandbox one, notstripe listen's) - [ ] One secret per endpoint: staging and production don't share
- [ ] No hardcoded fallback secret in code
- [ ] Redeployed after changing the variable (Vercel), or set with
supabase secrets set - [ ] A resent live event shows
200
WebhookProof's free checker can't see your secret, and no outside scan can. It does confirm the half of the problem that's visible from outside: whether Stripe can reach your live endpoint and whether that endpoint rejects unsigned events, using 3 harmless requests. The go-live steps above are also in the guide: Test vs live Stripe webhook secret.
WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-18.
Sources
- Stripe, Receive Stripe events in your webhook endpoint (secret per endpoint and per mode,
stripe listensecret, HMAC-SHA256 signing, rolling secrets and the 24-hour overlap, retries, resend windows, new signature per retry, why to verify): https://docs.stripe.com/webhooks - Stripe, Manage webhook endpoints: resolve signature verification errors (the error text, "at least one of the three parameters", wrong secret most common, CLI vs Dashboard secret): https://docs.stripe.com/webhooks/signature
- Stripe, API keys (signing secrets aren't API keys, sandbox vs live mode, go-live: copy each endpoint's new signing secret, don't commit keys): https://docs.stripe.com/keys
- Vercel, Environment variables (changes apply only to new deployments): https://vercel.com/docs/environment-variables
- Supabase, Environment variables / secrets (
supabase secrets set, no redeploy needed): https://supabase.com/docs/guides/functions/secrets
Top comments (0)