Skip to content

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.

ValidatorAjv with the 2020-12 dialect (Ajv2020)
$schemahttps://json-schema.org/draft/2020-12/schema
Formatsajv-formats is registered, so date, date-time, time, email, uri, uuid, ipv4, regex and the rest are enforced
Ajv optionValueWhat it means for you
allErrorstrueEvery problem in one response, not just the first. Fix the whole payload in one pass.
useDefaultstruedefault values are written into the payload before it renders.
strictfalseUnknown keywords are ignored rather than fatal, so annotations like example, title or your own metadata are allowed to sit in the schema.
allowUnionTypestrue"type": ["string", "number"] is legal.
coerceTypesfalseNo 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.

A schema that produces good errors
{
"$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:

KeywordWhat it does
requiredTurns a missing field into a 422 instead of a blank spot in the document. List every field the template cannot render without.
examplesThe first entry is echoed back as example in the field error, so the caller is shown a value that works.
descriptionRead by humans and by models through get_template. It is not echoed in field errors — put the machine-usable value in examples.
defaultApplied 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.

The validator walks this list and uses the first thing it finds on the failing property’s schema:

  1. examples[0]
  2. example
  3. default
  4. enum[0]
  5. A value derived from type and format"person@example.com" for format: "email", "2026-01-31" for format: "date", "https://example.com/logo.png" for format: "uri", 0 for 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.

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.

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.

POST /v1/templates/:ref/validate
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.

ValidateRender
CostFree1 unit per PNG/JPG, 1 per PDF page
Produces a fileNoYes
ErrorsSame field errors, valid: false, HTTP 200Same field errors, validation_error, HTTP 422
Applies defaultsTo the copy it checksTo the payload it renders and caches

The invoice starter schema, in full:

packages/templates/library/invoice/schema.json
{
"$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:

Terminal window
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 */ ]
}
}
FieldMeaning
pathA 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.
expectedA plain-language type: string, number <= 1, string (format: date), object (required), one of ["paid","overdue"], array with at least 1 items.
receivedmissing, null, array(3), or the JSON type of what you sent.
exampleA 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.

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.

400 invalid_request
{
"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.

  • 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 format for dates, emails and URLs; it is enforced.
  • Use minimum, maximum, minItems, enum where the domain is real. tax_rate between 0 and 1 is why the 1.5 above was caught rather than printed as a 150% tax line.
  • Use default for anything with a house style, such as currency.
  • Keep additionalProperties: true so callers can pass context you have not thought of.
  • Ship an example payload with the version. validate hands it back on every failure.