Why does my Stripe webhook keep returning a 400 error?
Signature verification failed. Either STRIPE_WEBHOOK_SECRET does not match the endpoint sending the event, or something read and re-encoded the request body before verification ran. Each endpoint in Stripe has its own secret, and the CLI's local secret is different again, which is the usual cause of a 400 that appears only in production.
Check the secret matches the endpoint
Stripe issues one signing secret per endpoint. A dashboard endpoint, a second dashboard endpoint for
staging, and stripe listen locally all have different secrets. Copying the wrong one produces
exactly this error with no other symptom.
bin/kamal app exec 'bin/rails runner "puts ENV[%q(STRIPE_WEBHOOK_SECRET)].to_s.first(8)"'
Compare the first characters against the endpoint's secret in the dashboard. Do not print the whole value into a terminal you will paste somewhere.
The raw body has to stay raw
Signature verification hashes the exact bytes Stripe sent. Any middleware that parses the JSON and
re-serializes it changes whitespace or key order, and the hash no longer matches. The handler in
app/controllers/webhooks/stripe_controller.rb reads request.body.read rather than params for
this reason, and adding a before_action that touches params first is enough to break it.
Test-mode and live-mode events are separate
An endpoint registered in test mode does not receive live events, and its secret does not verify
them. If you switched STRIPE_SECRET_KEY from sk_test_ to sk_live_ and left the webhook secret
alone, every live delivery fails. The two always move together.
Reproduce it locally
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger checkout.session.completed
stripe listen prints its own signing secret on startup. Export that one for local runs. If local
succeeds and production fails, the difference is the secret, not the code.
Read Stripe's delivery log rather than guessing
The dashboard records every attempt with the response body your endpoint returned. Stripe retries failures for up to three days, so a fixed secret usually backfills the missed events without you doing anything. Worth knowing before you go writing a manual reconciliation script.
When 400 is correct
If the request genuinely is not from Stripe, 400 is the right answer. The endpoint is public, it will receive scanner traffic, and rejecting unsigned requests is the point.