A webhook 400 Bad Request response means your endpoint rejected the request because the payload itself was malformed or missing data your handler considers required. Unlike 401 (authentication failure) or 5xx (server crash), 400 is a deliberate "I refuse to process this body" signal — and providers usually do not retry 400s, so a stuck 400 means the event is silently dropped.
Root Causes
1. Malformed JSON
Most webhook payloads are JSON. If your handler calls JSON.parse directly without a try/catch, a single byte of corruption (rare in practice with HTTPS, but possible with proxies that mis-handle binary content) throws and your error handler returns 400. The cause is usually upstream — a forwarder or middleware that mangled the body — not the source itself.
2. Missing required fields after parsing
Your Zod / Joi / Pydantic schema declares some payload fields as required. The provider sent a payload that doesn't include them — usually because (a) you subscribed to a different event type than you expected, or (b) the provider's schema evolved and a field you required is now optional. Always parse leniently for webhooks: pluck what you need, ignore unknown fields, default the missing ones.
3. Content-Type mismatch
Your route is configured for application/json but the source sent application/x-www-form-urlencoded (Slack, Twilio status callbacks) or application/xml. Express's body parser silently leaves req.body empty, your validation fails, and you 400. Match the parser to what the provider actually sends — see your provider's webhook docs page for the content-type they use.
4. Body size limits
Some webhook events are large. Shopify orders with many line items, GitHub push events with 100+ commits — payloads can hit hundreds of KB. If your reverse proxy or framework caps body size at 100 KB and the provider sent 250 KB, the body is truncated, JSON parse fails, you return 400. Raise the body size limit on the webhook route specifically.
Fix It
// Express — raise body limit + tolerant validation
app.post('/webhooks/stripe',
express.raw({ type: 'application/json', limit: '5mb' }),
(req, res) => {
let body
try {
body = JSON.parse(req.body.toString())
} catch {
// Don't 400 on parse errors — log and 200 so the source doesn't
// think your endpoint is broken. The event is logged for debug.
log.error({ raw: req.body }, 'malformed webhook body')
return res.status(200).send('parse-error-logged')
}
if (!body?.id || !body?.type) {
log.warn({ body }, 'webhook missing required fields')
return res.status(200).send('shape-error-logged')
}
// Process body…
}
)
Provider-Specific Notes
| Provider | Common 400 cause | Fix |
|---|---|---|
| Stripe | Body parsed before signature verification | Use express.raw(), not express.json(), on webhook routes |
| GitHub | Form-encoded vs JSON content-type | Set webhook to "application/json" in repo settings |
| Shopify | Body truncation at proxy | Raise body limit; some Shopify orders exceed 1 MB |
| Slack | x-www-form-urlencoded body | Use express.urlencoded() — Slack signs the form-encoded body |
How to Reproduce
Use WebhookWhisper's Test Sender to fire a sample payload at your endpoint. If your handler returns 400, capture the request in the inspector and compare the headers and body against what your handler expects. Most 400s are reproducible by sending a stripped-down version of the real payload — or by deliberately corrupting one byte to confirm your error path returns the right status.
Frequently Asked Questions
Why do providers stop retrying after 400?
Most providers treat 4xx as client error meaning 'this request is bad and retry won't help.' Stripe stops retrying on 400, 401, 403, 404, 410. If your handler is temporarily broken, return 5xx instead — providers retry 5xx automatically.
Should I always return 200 even on bad payloads?
For unrecoverable bad payloads (parse errors, schema violations) — yes, return 200 and log. The event is dropped, but the source isn't pestered with retries. For genuinely transient errors, return 5xx so retries kick in.
How do I tell parse errors from real validation failures?
Wrap JSON.parse in try/catch and log differently. Parse errors are usually transport bugs; validation failures are real schema mismatches you should investigate.