You're integrating Stripe webhooks. You paste a payment_intent.succeeded payload into a schema generator, wire the schema into your handler with Ajv, and it passes every test. Then production sends a payment_intent.payment_failed event — last_payment_error is now an object, payment_method is null — and your validator throws on a payload Stripe sent correctly. The schema was wrong from the start, because it was built from one sample. The fix is to generate it from several event payloads and merge them, so optional and nullable fields are detected automatically.
Merge a success, a failure, and a refund payload into one schema — in your browser:
The happy-path payload that fools you
Here's a simplified payment_intent.succeeded data object (the real one has more fields; these are the stable ones that matter for the example):
{
"id": "pi_3Success",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "succeeded",
"customer": "cus_Abc123",
"description": "Order #1001",
"last_payment_error": null,
"payment_method": "pm_1Card"
}
Feed that single sample to a normal schema generator and you get this — note last_payment_error typed as null, and customer, description, and payment_method all marked required strings:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": { "type": "string" },
"object": { "type": "string" },
"amount": { "type": "integer" },
"currency": { "type": "string" },
"status": { "type": "string" },
"customer": { "type": "string" },
"description": { "type": "string" },
"last_payment_error": { "type": "null" },
"payment_method": { "type": "string" }
},
"required": ["id", "object", "amount", "currency", "status",
"customer", "description", "payment_method"]
}
It validates the success case perfectly. It is also a production bug waiting for the first failed payment.
The event that breaks it
Now a real payment_intent.payment_failed arrives. The card was declined, so Stripe populates last_payment_error with an object and leaves the payment fields empty:
{
"id": "pi_3Failed",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "requires_payment_method",
"customer": "cus_Def456",
"description": null,
"last_payment_error": {
"code": "card_declined",
"message": "Your card was declined.",
"type": "card_error"
},
"payment_method": null
}
Against the single-sample schema, this valid Stripe payload fails validation in three separate ways.
Why it breaks: three causes
- Optional fields were marked required. The schema requires
descriptionandpayment_methodas strings. The failed event sendsdescription: nullandpayment_method: null— both rejected. - Field types were inferred too narrowly.
last_payment_errorwasnullin your one sample, so the schema froze its type asnull. A failed event sends an object there, which does not match. - Event payloads legitimately diverge. Every Stripe event shares one envelope —
id,type,api_version, and adata.object— but the object insidedatadiffers by event type and outcome, and is rendered per theapi_versionat creation time. Different shapes are expected. See Stripe's event types reference and webhooks guide.
You can spend an afternoon patching the schema by hand — flip last_payment_error to an object, make three fields optional, add null to their types — and still miss the next event type. There's a faster, more reliable way.
The fix: infer from multiple event samples
Capture a few different events and merge them. Add a third — a canceled PaymentIntent, which nulls or omits even more:
{
"id": "pi_3Canceled",
"object": "payment_intent",
"amount": 1500,
"currency": "usd",
"status": "canceled",
"customer": null,
"last_payment_error": null,
"payment_method": null
}
Paste all three — succeeded, payment_failed, canceled — into the Multi-Sample Schema Generator and merge. Fields missing or null in some events become optional or nullable; last_payment_error becomes "object or null". One schema now covers all three outcomes:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": { "type": "string" },
"object": { "type": "string" },
"amount": { "type": "integer" },
"currency": { "type": "string" },
"status": { "type": "string" },
"customer": { "type": ["string", "null"] },
"description": { "type": ["string", "null"] },
"payment_method": { "type": ["string", "null"] },
"last_payment_error": {
"anyOf": [
{
"type": "object",
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"type": { "type": "string" }
},
"required": ["code", "message", "type"]
},
{ "type": "null" }
]
}
},
"required": ["id", "object", "amount", "currency", "status"]
}
Notice what merging worked out automatically: customer, description, and payment_method are now nullable and no longer required; last_payment_error accepts both the error object and null; and only the fields present in every event stayed in required.
Wire it into your handler
Validate incoming events with Ajv against the merged schema — after verifying the signature:
const Ajv = require("ajv");
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(paymentIntentSchema); // the merged schema
app.post("/webhook", (req, res) => {
// 1. Authenticity: verify the Stripe signature first.
const event = stripe.webhooks.constructEvent(
req.body, req.headers["stripe-signature"], endpointSecret
);
// 2. Structure: validate the payload shape.
if (!validate(event.data.object)) {
console.error("Unexpected payload shape:", validate.errors);
return res.status(400).send("Bad payload");
}
// 3. Handle the event...
res.json({ received: true });
});
| Field | Single-sample schema | Merged schema |
|---|---|---|
last_payment_error | null only | object or null |
description | required string | optional, nullable |
payment_method | required string | optional, nullable |
customer | required string | nullable |
| Rejects valid failed events? | Yes | No |
Schema validation is not signature verification
These solve different problems and you want both. Signature verification — using the Stripe-Signature header and your endpoint secret — proves the request really came from Stripe and wasn't tampered with; do it first and reject anything that fails. Schema validation then confirms the payload has the structure your code expects. A valid signature on an unexpected shape, or a perfect shape with a bad signature, are both failures.
Webhook schemas evolve — re-merge periodically
Stripe adds fields to payloads over time, and the object you receive is rendered per the API version pinned to your endpoint. New optional fields are usually non-breaking, but when you adopt a new event type or bump your API version, capture a fresh sample and re-merge. Keeping the schema generated from real payloads — rather than hand-maintained — makes that a 30-second task instead of an audit.
Frequently Asked Questions
Why does my Stripe webhook validator reject payment_failed events?
Almost always because the schema was generated from a single succeeded payload. In that payload last_payment_error is null, so the schema records its type as null — but a failed event sends last_payment_error as an object, which the schema rejects. The same happens for fields like description or payment_method that are present on success but null or absent on failure.
Why do Stripe events have different JSON shapes?
Every Stripe event shares the same envelope — id, type, api_version, and a data.object — but the object inside data differs by event type and by outcome. A succeeded PaymentIntent populates fields that a failed or canceled one leaves null or absent, and the object is rendered according to the API version at the time the event was created. So multiple shapes are expected, not a bug.
How do I generate a schema that handles all Stripe event types?
Capture several real payloads — at minimum a success, a failure, and a refund or cancellation — and merge them with a multi-sample schema generator. Fields missing or null in some events automatically become optional or nullable, so one schema validates every case instead of just the happy path.
Does Stripe publish an official JSON Schema for webhooks?
Stripe documents every event type and the object each one contains, and publishes an OpenAPI specification, but most teams still want a lightweight JSON Schema scoped to the handful of events they actually handle. Generating one from real captured payloads keeps it small and matched to the API version you receive.
Is schema validation the same as verifying the webhook signature?
No, and you need both. Signature verification (using the Stripe-Signature header and your endpoint secret) proves the request genuinely came from Stripe and was not tampered with — always do this first. Schema validation then checks that the payload has the shape your code expects. One is about authenticity, the other about structure.
How many Stripe payloads should I merge?
Start with three that exercise the differences: a succeeded event (the happy path), a payment_failed event (populates last_payment_error, nulls other fields), and a canceled or refunded event (omits or nulls more fields). Add more event types as you handle them. The merger only learns the variation it sees.
Stop patching webhook schemas by hand
Capture a success, a failure, and a refund payload, merge them in your browser, and get one schema that validates every event.
Have a few captured Stripe payloads? Merge them into one schema now.
Open the Multi-Sample Generator →