JSON Schema validation case study: an order webhook
· 10 min read
This is a worked case of JSON Schema validation from start to finish. A small online shop receives an order.created webhook from a sales platform and forwards each order to its warehouse. Most orders go through. Every so often one doesn’t: a quantity arrives as a string, an email is missing, or the shipping country is spelled out instead of coded. The warehouse import then fails somewhere far from the cause.
The fix is a schema that checks each payload on arrival and says exactly what is wrong. The shop, the platform and the payloads are made up for this article. Every validator output below is real, produced by the JSON Schema validator on this site and by its MCP counterpart, so you can paste the same files and get the same results.
Two payloads
The first payload is a normal order:
{
"event": "order.created",
"id": "evt_1042",
"created_at": "2026-10-20T09:15:00Z",
"order": {
"number": "A-1042",
"customer": { "email": "ann@example.com", "name": "Ann Lee" },
"currency": "EUR",
"items": [
{ "sku": "MUG-01", "quantity": 2, "price_cents": 1250 },
{ "sku": "TEA-GREEN-100", "quantity": 1, "price_cents": 890 }
],
"shipping": { "country": "DE", "city": "Berlin", "postcode": "10115" },
"discount": { "type": "coupon", "code": "AUTUMN10", "percent": 10 }
}
}
The second one broke the import. It parses fine, which is the whole problem: “valid JSON” says nothing about the content.
{
"event": "order.created",
"id": "evt_1043",
"created_at": "2026-10-20 09:17",
"order": {
"number": "A-1043",
"customer": { "name": "Bob Stone" },
"currency": "eur",
"items": [
{ "sku": "MUG-01", "quantity": "2", "price_cents": 12.5 },
{ "sku": "", "quantity": 0, "price_cents": 890 }
],
"shipping": { "country": "Germany", "city": "Munich" },
"discount": { "type": "coupon", "percent": 10 }
}
}
Putting the two side by side in the JSON diff tool lists the changed values, but a diff can’t say which differences are errors. "Bob Stone" instead of "Ann Lee" is fine. "2" instead of 2 is not. That judgement is what the schema has to encode.
Step 1: a schema made from a sample
A quick first draft, written from the good payload alone, often looks like this: every field typed, nothing required.
{
"type": "object",
"properties": {
"event": { "type": "string" },
"id": { "type": "string" },
"created_at": { "type": "string" },
"order": {
"type": "object",
"properties": {
"number": { "type": "string" },
"customer": { "type": "object" },
"currency": { "type": "string" },
"items": { "type": "array" },
"shipping": { "type": "object" },
"discount": { "type": "object" }
}
}
}
}
Validated against this schema, the broken payload passes. So does {}. Each field that is present has the declared top-level type, and nothing says a field must be present or what goes inside items or customer. A schema like this looks like protection in code review and catches almost nothing.
The lesson from step 1: a generated schema is a list of the fields you saw, not a set of rules. The rules have to be added by hand, and the test for each rule is a payload that breaks it.
Step 2: required fields and real types
The second version says what the warehouse needs: which fields must exist, what each item looks like, and which values are allowed.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["event", "id", "created_at", "order"],
"properties": {
"event": { "const": "order.created" },
"id": { "type": "string", "pattern": "^evt_[0-9]+$" },
"created_at": { "type": "string", "format": "date-time" },
"order": {
"type": "object",
"required": ["number", "customer", "currency", "items", "shipping"],
"properties": {
"number": { "type": "string", "minLength": 1 },
"customer": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "format": "email" },
"name": { "type": "string" }
}
},
"currency": { "enum": ["EUR", "USD", "GBP"] },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity", "price_cents"],
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"price_cents": { "type": "integer", "minimum": 0 }
}
}
},
"shipping": {
"type": "object",
"required": ["country", "city", "postcode"],
"properties": {
"country": { "type": "string", "pattern": "^[A-Z]{2}$" },
"city": { "type": "string" },
"postcode": { "type": "string" }
}
},
"discount": { "type": "object" }
}
}
}
}
The good payload still passes. The broken one now fails with nine errors, each with a path to the value:
| Path | Error | Keyword |
|---|---|---|
$.created_at |
String does not match format “date-time” | format |
$.order.customer |
Instance does not have required property “email” | required |
$.order.currency |
Instance does not match any of [“EUR”,“USD”,“GBP”] | enum |
$.order.items[0].quantity |
Instance type “string” is invalid. Expected “integer” | type |
$.order.items[0].price_cents |
Instance type “number” is invalid. Expected “integer” | type |
$.order.items[1].sku |
String is too short (0 < 1) | minLength |
$.order.items[1].quantity |
0 is less than 1 | minimum |
$.order.shipping |
Instance does not have required property “postcode” | required |
$.order.shipping.country |
String does not match pattern | pattern |
Three choices in this version are worth a closer look.
Prices are integers in cents. 12.5 fails "type": "integer", which catches a payload that sent euros where cents were expected. With "type": "number" the order would have gone through at a price of twelve and a half cents.
created_at uses format: date-time, which follows RFC 3339. "2026-10-20 09:17" has a space instead of T, no seconds and no time zone, so it fails. Whether a validator checks format at all depends on the library and its settings. The validator on this site checks it by default.
The postcode error is the schema being wrong, not the data. Some countries don’t use postal codes, so requiring one would reject valid orders. A schema encodes business rules, and a rule copied from one sample can be too strict as easily as too loose. In the final version postcode is optional.
Step 3: a rule that depends on another field
The broken payload has a coupon discount with no coupon code. In this shop, a coupon discount needs code and percent, and a fixed discount needs amount_cents. There are two common ways to write that.
The first uses oneOf with one branch per discount type. For { "type": "coupon", "percent": 10 } it reports:
| Path | Error | Keyword |
|---|---|---|
$ |
Instance does not match exactly one subschema (0 matches) | oneOf |
That’s correct and unhelpful. The validator tried both branches, both failed, and the message doesn’t say why. The second way uses if / then / else:
{
"type": "object",
"required": ["type"],
"properties": { "type": { "enum": ["coupon", "fixed"] } },
"if": { "properties": { "type": { "const": "coupon" } } },
"then": {
"required": ["code", "percent"],
"properties": {
"code": { "type": "string", "minLength": 1 },
"percent": { "type": "number", "exclusiveMinimum": 0, "maximum": 100 }
}
},
"else": {
"required": ["amount_cents"],
"properties": { "amount_cents": { "type": "integer", "minimum": 1 } }
}
}
The same discount now gives an error that names the missing field:
| Path | Error | Keyword |
|---|---|---|
$ |
Instance does not match “then” schema | if |
$ |
Instance does not have required property “code” | required |
Note the "required": ["type"] at the top. An if subschema with only properties also matches an object that has no type at all, so without that line a discount with no type would be checked against the coupon rules.
Step 4: fields nobody announced
A month later the platform adds a gift_wrap flag to orders. Had the schema used "additionalProperties": false on order, every new order would fail:
| Path | Error | Keyword |
|---|---|---|
$.order.gift_wrap |
Property “gift_wrap” is not allowed | additionalProperties |
additionalProperties: false is a good default for data you produce, such as your own API responses or config files, where an unknown field is almost always a typo. For data from a partner it turns every addition on their side into an outage on yours. This case keeps incoming objects open and leaves unknown fields to logging. The rule is a decision per boundary, and it’s easy to get wrong in both directions.
Step 5: one definition per shape
Orders gain a billing address with the same shape as shipping. Rather than copy the rules, the final schema moves items, addresses and discounts into $defs and points at them with $ref:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["event", "id", "created_at", "order"],
"properties": {
"event": { "const": "order.created" },
"id": { "type": "string", "pattern": "^evt_[0-9]+$" },
"created_at": { "type": "string", "format": "date-time" },
"order": {
"type": "object",
"required": ["number", "customer", "currency", "items", "shipping"],
"properties": {
"number": { "type": "string", "minLength": 1 },
"customer": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "format": "email" },
"name": { "type": "string" }
}
},
"currency": { "enum": ["EUR", "USD", "GBP"] },
"items": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/item" } },
"shipping": { "$ref": "#/$defs/address" },
"billing": { "$ref": "#/$defs/address" },
"discount": { "$ref": "#/$defs/discount" }
}
}
},
"$defs": {
"item": {
"type": "object",
"required": ["sku", "quantity", "price_cents"],
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"price_cents": { "type": "integer", "minimum": 0 }
}
},
"address": {
"type": "object",
"required": ["country", "city"],
"properties": {
"country": { "type": "string", "pattern": "^[A-Z]{2}$" },
"city": { "type": "string", "minLength": 1 },
"postcode": { "type": "string" }
}
},
"discount": {
"type": "object",
"required": ["type"],
"properties": { "type": { "enum": ["coupon", "fixed"] } },
"if": { "properties": { "type": { "const": "coupon" } } },
"then": {
"required": ["code", "percent"],
"properties": {
"code": { "type": "string", "minLength": 1 },
"percent": { "type": "number", "exclusiveMinimum": 0, "maximum": 100 }
}
},
"else": {
"required": ["amount_cents"],
"properties": { "amount_cents": { "type": "integer", "minimum": 1 } }
}
}
}
}
Paste it into the JSON Schema validator with the broken payload as data. The result is ten errors: the eight real ones from step 2 (the postcode error is gone) plus the two from the discount rule. The good payload passes, and so does the one with gift_wrap. If a payload in your own logs is one long line, run it through the JSON formatter first so the error paths are easy to follow.
Running it on every payload
A schema helps only if something runs it. For this case there are three places.
While writing the schema, the browser tool is the fastest loop: change a rule, see which errors appear or disappear, and click an error to jump to the value. Nothing is uploaded, so real customer payloads can be pasted safely.
When debugging with an AI assistant, the json_schema_validate tool in the devtoolbox.cc MCP server reads the schema and the payload from files, so neither has to be pasted into the chat. For the broken order it returns:
Invalid: 10 errors (draft 2020-12).
- /created_at: String does not match format "date-time" (format at #/properties/created_at/format)
- /order/customer: Instance does not have required property "email" (required at #/properties/order/properties/customer/required)
- /order/currency: Instance does not match any of ["EUR","USD","GBP"] (enum at #/properties/order/properties/currency/enum)
- /order/items/0/quantity: Instance type "string" is invalid. Expected "integer" (type at #/properties/order/properties/items/items/$ref/properties/quantity/type)
- /order/items/0/price_cents: Instance type "number" is invalid. Expected "integer" (type at #/properties/order/properties/items/items/$ref/properties/price_cents/type)
- /order/items/1/sku: String is too short (0 < 1) (minLength at #/properties/order/properties/items/items/$ref/properties/sku/minLength)
- /order/items/1/quantity: 0 is less than 1 (minimum at #/properties/order/properties/items/items/$ref/properties/quantity/minimum)
- /order/shipping/country: String does not match pattern (pattern at #/properties/order/properties/shipping/$ref/properties/country/pattern)
- /order/discount: Instance does not match "then" schema (if at #/properties/order/properties/discount/$ref/if)
- /order/discount: Instance does not have required property "code" (required at #/properties/order/properties/discount/$ref/then/required)
In production, the webhook handler validates with a library in its own language before doing anything else, and answers with an error status when validation fails, so the platform’s delivery log shows the failure. The same schema file works in all three places. The guide to choosing a JSON Schema validator compares libraries and their defaults, which differ more than you might expect.
What the case shows
The final schema catches every problem in the broken payload, and each error points at one value. Getting there took five changes, and each one came from a specific failure:
| Step | Change | What it fixed |
|---|---|---|
| 2 | required, integer, enum, format |
The generated schema accepted anything |
| 2 | postcode made optional |
A rule copied from one sample was too strict |
| 3 | if / then instead of oneOf |
“0 matches” didn’t say what was missing |
| 4 | No additionalProperties: false here |
A new partner field would have rejected orders |
| 5 | $defs and $ref |
Shipping and billing rules would have drifted |
The pattern repeats for any JSON that crosses a boundary: start from real samples, add rules one at a time, and keep a broken sample for each rule to prove it fires. The JSON Schema explained article covers the keywords used here in more detail, and the validator is the quickest place to try them.