Troubleshooting·6 min read·

Stripe webhook 400 errors — signature validation gotchas

A 400 from your Stripe webhook endpoint almost always means a signature mismatch. Here's how webhook signing works, the five most common mistakes, and how to verify your setup is correct.

A 400 response from your Stripe webhook endpoint almost always means one of two things: the raw request body was consumed before signature verification (the most common mistake in Next.js), or the STRIPE_WEBHOOK_SECRET environment variable doesn't match the endpoint's signing secret in the Stripe Dashboard.

Why raw body consumption matters

Stripe signs the raw request body using HMAC-SHA256. The signature is in the Stripe-Signature header. To verify it, your endpoint must pass the exact same raw bytes to stripe.webhooks.constructEvent() — not a JSON-parsed version, not a body-string reconstructed from JSON.stringify(). In Next.js App Router, calling await request.json() before constructEvent() will cause this — the JSON parsing normalises whitespace and field ordering, changing the bytes and invalidating the signature.

// WRONG — json() parses first, raw bytes are lost:
const body = await request.json();
stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret);

// CORRECT — use text() to preserve raw bytes:
const rawBody = await request.text();
const event = stripe.webhooks.constructEvent(rawBody, sig, secret);

Check 2 — Local vs. production webhook secrets

The Stripe CLI's stripe listen command generates a temporary webhook signing secret that's different from your production endpoint's signing secret in the Dashboard. Using the CLI secret in production (or vice versa) causes a persistent 400. STRIPE_WEBHOOK_SECRET in your .env.local should be the CLI secret for local development. The same variable in Railway production should be the Dashboard endpoint secret — these must be two different values.

A common mistake: copying the signing secret from the Stripe CLI output into your Railway production environment variables. The CLI secret rotates every time you run stripe listen. Use the Dashboard endpoint's signing secret (found in Stripe Dashboard Developers Webhooks your endpoint Signing secret) for production.

Check 3 — Webhook URL must be exact

The Stripe Dashboard registers webhooks to a specific URL — if your endpoint is at /api/stripe/webhook, the registered URL must be exactly https://yoursite.velocms.org/api/stripe/webhook (no trailing slash, correct subdomain). A mismatch — even a trailing slash — means Stripe sends to a different URL than your handler, so the handler never sees the request.

Testing without Stripe CLI

To test in production without the Stripe CLI, use Stripe Dashboard Developers Webhooks your endpoint Send test webhook. Pick the event type you want to test (checkout.session.completed is the most common for VeloCMS), click Send, and watch the Railway logs for the request. The test webhook uses your production signing secret, so a successful 200 response confirms your setup is correct.