A webhook 422 Unprocessable Entity response means the body parsed correctly (so it's valid JSON or form-encoded data) but failed your application's domain validation. Unlike 400 (the body is malformed) or 401 (the signature is wrong), 422 is your handler explicitly saying "I understood this, but I refuse it." Many webhook handlers conflate 400 and 422, but they have different operational implications.
Root Causes
1. Schema validation rejected a real-but-evolved payload
Your Zod / Joi / Pydantic schema is strict and the provider added a field, removed a field, or changed a type. Strict-mode validators reject "unexpected" properties — and on the next provider update, your handler 422s every delivery. The fix is lenient parsing: pluck the fields you need, ignore the rest, treat unknown fields as forward-compatible additions, not errors.
2. Type coercion bugs
The provider sent "amount": "1000" as a string and your schema expected number. Or it sent "created": 1697045123 as a Unix timestamp and you expected ISO-8601. Both are common when a provider's API has both v1 and v2 schemas in flight, or when an event predates a payload upgrade.
3. Cross-event-type rejection
You subscribed to charge.succeeded but Stripe sent charge.refunded because of a misconfigured filter. Your handler validates against the charge.succeeded shape (which has fields the refund event lacks) and 422s. Always switch on the type field first, validate per-event-type schema second.
4. Idempotency-store conflicts
Some teams use 422 to signal "I've already processed this event." Better to use 200 + a header, or just 200 — 422 here is unusual and confuses providers' retry logic.
Fix It
// Lenient parsing — Zod with passthrough
import { z } from 'zod'
const StripeChargeEvent = z.object({
id: z.string(),
type: z.literal('charge.succeeded'),
data: z.object({
object: z.object({
id: z.string(),
amount: z.number(),
currency: z.string(),
}).passthrough(), // unknown fields allowed
}).passthrough(),
}).passthrough()
// Switch on type FIRST, then validate per-type
function handle(payload: any) {
switch (payload.type) {
case 'charge.succeeded':
return handleChargeSucceeded(StripeChargeEvent.parse(payload))
case 'charge.refunded':
return handleChargeRefunded(StripeRefundEvent.parse(payload))
default:
// Don't 422 unknown types — log and 200
log.info({ type: payload.type }, 'unhandled event type')
return { status: 200 }
}
}
422 vs 400 — When to Use Which
| Status | Meaning | Webhook context |
|---|---|---|
| 400 Bad Request | The body itself is malformed (parse error, syntax error) | Rare — usually transport bugs |
| 422 Unprocessable Entity | The body is well-formed but fails domain validation | Schema mismatch, missing required fields |
How to Reproduce
Capture a 422-returning delivery in WebhookWhisper or your provider's dashboard. Re-fire the exact body using the inspector's "replay" feature. If your handler still 422s on the captured body, the validation rule is the bug — not transport. Diff the captured body against your schema's expectations.
Frequently Asked Questions
Will providers retry a 422?
Stripe stops retrying on 4xx by default. GitHub retries 4xx anyway. Shopify retries everything except 410. Don't rely on retry behavior to bail you out — fix the validation.
Should my handler ever return 422?
Rarely. Most webhook handlers should return 200 on validation failure (logged, dropped) or 5xx if the failure is transient. 422 only makes sense if the source is configured to act on 422 specifically — almost no webhook senders do.
How do I make schemas resilient to provider changes?
Use lenient validators (Zod's .passthrough(), Joi's .unknown(true)). Validate only the fields your handler actually uses. Treat unknown fields as forward compatibility, not errors.