Data schemas
Every template version stores a JSON Schema for its data payload. It is optional in the sense
that the API accepts a version without one — and a mistake in every other sense.
Without a schema, a payload with a typo renders a blank page and costs you a unit. With one, the
same payload fails at 422 before anything is rendered, and the error names the field, the type
it wanted, what you sent, and a value that would have worked. That error is readable by a person
and directly actionable by a model, which is the whole point.
Draft and validator
Section titled “Draft and validator”| Validator | Ajv with the 2020-12 dialect (Ajv2020) |
$schema | https://json-schema.org/draft/2020-12/schema |
| Formats | ajv-formats is registered, so date, date-time, time, email, uri, uuid, ipv4, regex and the rest are enforced |
| Ajv option | Value | What it means for you |
|---|---|---|
allErrors | true | Every problem in one response, not just the first. Fix the whole payload in one pass. |
useDefaults | true | default values are written into the payload before it renders. |
strict | false | Unknown keywords are ignored rather than fatal, so annotations like example, title or your own metadata are allowed to sit in the schema. |
allowUnionTypes | true | "type": ["string", "number"] is legal. |
coerceTypes | false | No coercion. "3.2" is not a number and "true" is not a boolean. Send correct JSON types. |
Older drafts mostly validate fine under the 2020-12 dialect, but write new schemas against
2020-12: use prefixItems rather than tuple-form items, and $defs rather than definitions.
The schema is compiled once per template version and cached against the version’s checksum, so validation costs nothing at render time.
Writing one
Section titled “Writing one”{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Invoice", "type": "object", "required": ["invoice_number", "issued_on", "seller", "buyer", "line_items"], "additionalProperties": true, "properties": { "invoice_number": { "type": "string", "description": "Your invoice reference.", "examples": ["INV-1042"] }, "issued_on": { "type": "string", "format": "date", "examples": ["2026-09-16"] }, "currency": { "type": "string", "default": "USD", "examples": ["USD"] } }}Four keywords do most of the work:
| Keyword | What it does |
|---|---|
required | Turns a missing field into a 422 instead of a blank spot in the document. List every field the template cannot render without. |
examples | The first entry is echoed back as example in the field error, so the caller is shown a value that works. |
description | Read by humans and by models through get_template. It is not echoed in field errors — put the machine-usable value in examples. |
default | Applied to the payload before rendering, and offered as the error example when examples is absent. |
Keep additionalProperties: true unless you have a reason not to. The starters do, so a caller
can pass extra context without being rejected for it.
How the error example is chosen
Section titled “How the error example is chosen”The validator walks this list and uses the first thing it finds on the failing property’s schema:
examples[0]exampledefaultenum[0]- A value derived from
typeandformat—"person@example.com"forformat: "email","2026-01-31"forformat: "date","https://example.com/logo.png"forformat: "uri",0for a number,[]for an array,{}for an object.
Giving every property an examples entry is the single highest-value thing you can do to a
schema.
Defaults are applied for real
Section titled “Defaults are applied for real”useDefaults is on, and validation happens before the render and before the cache key is
computed. A default in the schema is therefore not documentation — it lands in the payload.
Send this to the invoice starter:
{ "invoice_number": "INV-1042", "issued_on": "2026-09-16", "seller": { "name": "Galley Render" }, "buyer": { "name": "Acme Robotics" }, "line_items": [{ "description": "Starter plan", "quantity": 1, "unit_price": 19 }]}and the payload the template actually sees is:
{ "invoice_number": "INV-1042", "issued_on": "2026-09-16", "seller": { "name": "Galley Render" }, "buyer": { "name": "Acme Robotics" }, "line_items": [{ "description": "Starter plan", "quantity": 1, "unit_price": 19 }], "currency": "USD"}because the invoice schema declares "currency": { "type": "string", "default": "USD" }.
Two limits inherited from Ajv: defaults are only applied to properties of objects and to array
items, never at the root, and they are ignored inside anyOf, oneOf and if/then.
Dry-running a payload
Section titled “Dry-running a payload”POST /v1/templates/:ref/validate runs exactly the validation a render runs, renders nothing, and
is free. Use it before a batch, or any time you are assembling a payload from a source you do not
control.
curl -sS https://api.galleyrender.com/v1/templates/invoice@1/validate \ -H "Authorization: Bearer $GALLEY_API_KEY" \ -H 'content-type: application/json' \ -d '{ "data": { "invoice_number": "INV-1042" } }'{ "object": "validation", "template": "invoice@1", "valid": false, "errors": [ /* … */ ], "example": { "invoice_number": "INV-1042", "issued_on": "2026-09-16", "…": "…" }}example is the payload stored with that template version — a complete, working data object you
can start from. template echoes the resolved ref, so you can see which version you were
checked against.
From an agent this is the validate_data tool, with the same arguments and the same response.
See The MCP server.
| Validate | Render | |
|---|---|---|
| Cost | Free | 1 unit per PNG/JPG, 1 per PDF page |
| Produces a file | No | Yes |
| Errors | Same field errors, valid: false, HTTP 200 | Same field errors, validation_error, HTTP 422 |
| Applies defaults | To the copy it checks | To the payload it renders and caches |
A worked example
Section titled “A worked example”The invoice starter schema, in full:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Invoice", "type": "object", "required": ["invoice_number", "issued_on", "seller", "buyer", "line_items"], "additionalProperties": true, "properties": { "invoice_number": { "type": "string", "description": "Your invoice reference.", "examples": ["INV-1042"] }, "issued_on": { "type": "string", "format": "date", "examples": ["2026-09-16"] }, "due_on": { "type": "string", "format": "date", "examples": ["2026-10-16"] }, "currency": { "type": "string", "default": "USD", "examples": ["USD"] }, "notes": { "type": "string", "examples": ["Thanks for your business."] }, "seller": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "examples": ["Galley Render"] }, "email": { "type": "string", "format": "email", "examples": ["billing@galleyrender.com"] }, "address": { "type": "string", "examples": ["2727 Jean Lafitte Dr, Fernandina Beach, FL 32034"] }, "logo_url": { "type": "string", "format": "uri", "examples": ["https://example.com/logo.png"] } } }, "buyer": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string", "examples": ["Acme Robotics"] }, "email": { "type": "string", "format": "email", "examples": ["ap@acme.test"] }, "address": { "type": "string", "examples": ["100 Market St, Austin, TX 78701"] } } }, "line_items": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["description", "quantity", "unit_price"], "properties": { "description": { "type": "string", "examples": ["Rendering, September"] }, "quantity": { "type": "number", "minimum": 0, "examples": [2] }, "unit_price": { "type": "number", "minimum": 0, "examples": [19] } } } }, "tax_rate": { "type": "number", "minimum": 0, "maximum": 1, "examples": [0.07] } }}Now a payload with six things wrong with it — a number where a string belongs, a European date
string, a missing buyer, a line item missing unit_price with its quantity sent as a string,
and a tax rate expressed as a percentage rather than a fraction:
curl -sS https://api.galleyrender.com/v1/templates/invoice@1/validate \ -H "Authorization: Bearer $GALLEY_API_KEY" \ -H 'content-type: application/json' \ -d '{ "data": { "invoice_number": 1042, "issued_on": "16/09/2026", "seller": { "name": "Galley Render" }, "line_items": [ { "description": "Starter plan, September", "quantity": 1, "unit_price": 19 }, { "description": "Overage", "quantity": "3.2" } ], "tax_rate": 1.5 } }'Every one of them comes back at once:
{ "object": "validation", "template": "invoice@1", "valid": false, "errors": [ { "path": "data.buyer", "message": "data.buyer is required", "expected": "object (required)", "received": "missing", "example": {} }, { "path": "data.invoice_number", "message": "data.invoice_number must be string", "expected": "string", "received": "number", "example": "INV-1042" }, { "path": "data.issued_on", "message": "data.issued_on must match format \"date\"", "expected": "string (format: date)", "received": "string", "example": "2026-09-16" }, { "path": "data.line_items[1].unit_price", "message": "data.line_items[1].unit_price is required", "expected": "number (required)", "received": "missing", "example": 19 }, { "path": "data.line_items[1].quantity", "message": "data.line_items[1].quantity must be number", "expected": "number", "received": "string", "example": 2 }, { "path": "data.tax_rate", "message": "data.tax_rate must be <= 1", "expected": "number <= 1", "received": "number", "example": 0.07 } ]}The same six errors come back from POST /v1/render as a 422:
{ "error": { "type": "validation_error", "message": "The data payload does not match the template schema.", "docs_url": "https://galleyrender.com/docs/errors/validation_error", "errors": [ /* identical to the list above */ ] }}Reading the fields
Section titled “Reading the fields”| Field | Meaning |
|---|---|
path | A dot path into your payload, rooted at data. Array members are indexed: data.line_items[1].quantity. |
message | <path> is required, or <path> followed by the validator’s own phrasing. |
expected | A plain-language type: string, number <= 1, string (format: date), object (required), one of ["paid","overdue"], array with at least 1 items. |
received | missing, null, array(3), or the JSON type of what you sent. |
example | A value that would be accepted for this field. Present whenever the schema gives enough to derive one. |
Errors are deduplicated by path plus the failing keyword, so one wrong field produces one entry
even when several constraints trip on it. Note the ordering: required errors on a parent object
surface before the type errors on its children, because Ajv reports the parent’s failure first.
Schema mistakes the API catches
Section titled “Schema mistakes the API catches”The schema itself is compiled at template create time, so a broken schema is a 400 you get
immediately rather than a 500 at render time.
{ "error": { "type": "invalid_request", "message": "`schema` is not a valid JSON Schema: schema is invalid: data/properties/total/type must be equal to one of the allowed values", "errors": [ { "path": "schema", "message": "schema is invalid: data/properties/total/type must be equal to one of the allowed values", "expected": "a valid JSON Schema (draft 2020-12)", "example": { "type": "object", "properties": { "title": { "type": "string" } }, "required": ["title"] } } ] }}Passing a non-object — a string, an array, [] — gets the same 400 with
"expected": "object". Passing null or omitting schema entirely is accepted and means “no
validation”, which is what you do not want.
Checklist
Section titled “Checklist”- Put every field the template cannot render without in
required. - Give every property
examples— that is what the caller is shown when it goes wrong. - Give every property a
description— that is what a model reads before it builds a payload. - Use
formatfor dates, emails and URLs; it is enforced. - Use
minimum,maximum,minItems,enumwhere the domain is real.tax_ratebetween 0 and 1 is why the1.5above was caught rather than printed as a 150% tax line. - Use
defaultfor anything with a house style, such ascurrency. - Keep
additionalProperties: trueso callers can pass context you have not thought of. - Ship an
examplepayload with the version.validatehands it back on every failure.