The canonical Stack Overflow question for this exact message has 89,819 views and 28 answers — more traffic than almost any other Stripe integration error. Despite the message telling you to check the signing secret, that's the actual cause in a minority of cases once you've looked at it twice. Here are the 6 real causes, ranked by how often each one actually happens.
Every webhook endpoint you register in the Stripe Dashboard — and every one the Stripe CLI creates with stripe listen — gets its own whsec_... value. If you have a test-mode endpoint, a live-mode endpoint, and a CLI session running locally, that's three different secrets. Copying the secret from the wrong one is the single most common cause of this error. Check: does the secret in your code match the one shown next to this specific endpoint in Dashboard → Developers → Webhooks, not just "a" webhook secret from memory or an old .env?
stripe listen prints its own temporary whsec_... every time you run it — it is not the same secret as your Dashboard endpoint, and it changes on every restart unless you pin it. Code that worked against the CLI locally will fail against Dashboard-sent events with this exact error if you forgot to swap the secret for deployment, and vice versa.
Signature verification needs the exact bytes Stripe sent. Some reverse proxies decompress and re-compress gzip content, normalize line endings, or re-encode the character set in transit. Any of that changes the byte sequence without changing what a human reading the JSON would notice, and the HMAC no longer matches. This is the hardest one to catch because the body looks identical when you log it as text — you have to compare raw byte length, not the printed string.
A body-parser, a logging middleware that reads and re-writes the stream, or a framework that parses JSON globally before your route runs — any of these consume or transform the raw stream. Unlike the sibling error ("Webhook payload must be provided as a string or a Buffer"), this variant shows up when the framework silently hands you a string, just not the original one — for example after being decoded and re-encoded with a different line-ending convention.
If you copied a payload from the Dashboard's "resend" view, from a log file, or from a bug report and pasted it into a test script, any editor that changed straight quotes to curly quotes, added a trailing newline, or re-indented the JSON has changed the bytes. The signature was computed on the original bytes; your edited copy will never verify. Use Stripe CLI's actual replay (stripe events resend) instead of copy-paste for this reason.
Behind a load balancer, if one instance was deployed before you rotated STRIPE_WEBHOOK_SECRET and another after, roughly half your webhook traffic fails this check — intermittently, which makes it look random until you check whether every instance actually restarted after the env change.
Want the raw Stripe-Signature header itself checked without exposing your webhook secret? The free signature validator parses the header format and timestamp — it does not need your whsec_ value, so there's nothing sensitive to paste.
This is one of 7 checks (signature verification, idempotency, raw-body middleware order, timestamp tolerance, secret key hygiene, amount trust boundary, event dedup) we run by hand against your actual repo, not a generic checklist:
Get the $39 Stripe Integration Audit — real review of your repo, 48h turnaround
Full refund if we can't access a public repo to review.