## 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.

```bash
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

```bash
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.
