Answers · Billing

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.

Related questions

Do I have to handle failed payments and refunds myself?

No. Stripe retries failed charges, emails customers, and processes refunds through its dashboard. Your app subscribes to invoice.payment_failed, invoice.payment_succeeded, charge.refunded and customer.subscription.deleted, and keeps one local Subscription row in sync. That is the whole of your responsibility.

What is a statement descriptor and why set it before charging?

It is the text that appears on a customer's card statement next to the charge. Set it in the Stripe dashboard before you take any money. Changing it later only affects future charges, so early customers keep whatever was there, and an unrecognizable descriptor is one of the most common causes of a chargeback.

Which Stripe webhook events does a subscription app need?

Eight: checkout.session.completed, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, charge.refunded, charge.dispute.created and radar.early_fraud_warning.created. The first five keep entitlement correct; the last three are the money events you want to know about rather than discover in a statement.