Status: the v1 schema is implemented and live behind both
/generate and the playground. It’s pre-1.0 and may still grow (additively); it isn’t frozen as a versioned public contract yet.{{path}} substitution — compute values in your code) and nodes mirror layout, not meaning (column, row, text, … — not invoice, lineItem). See Template basics.
Request envelope
200 application/pdf. Failures are Problem Details.
page
| Field | Type | Required | Notes |
|---|---|---|---|
size | "A4" · "A3" · "Letter" · "Legal" | no | Default A4. |
margin | number (points) | no | Default 30. Uniform on all four sides. Must be ≥ 0. |
defaultTextStyle | Style | no | Page-level default. Only fontSize and color propagate; bold/semiBold/italic apply only where set on a node’s own style. |
header | Node | no | Repeats on every page. |
content | Node | yes | The page body. |
footer | Node | no | Repeats on every page. |
Nodes
Every node has a"type" discriminator. There are seven types.
column
Vertical stack. → guide: Layout| Field | Type | Required | Notes |
|---|---|---|---|
items | Node[] | yes | Children, top to bottom. |
spacing | number | no | Gap between successive items. |
row
Horizontal stack of sized cells. → guide: Layout| Field | Type | Required | Notes |
|---|---|---|---|
items[].size | {kind:"relative",weight} · {kind:"constant",width} | yes | relative cells share remaining width by weight; constant cells are width points. |
items[].child | Node | yes | Cell content. |
text
A single styled string. → guide: Text & styling| Field | Type | Required | Notes |
|---|---|---|---|
value | string | yes | Supports {{path}} interpolation. |
style | Style | no | Merged over defaultTextStyle. |
align | "left" · "center" · "right" | no | Default left. |
richText
Multi-span text with mixed styling and page-number tokens. → guide: Text & styling| Field | Type | Required | Notes |
|---|---|---|---|
spans | Span[] | yes | At least one. Rendered inline, in order. |
align | "left" · "center" · "right" | no | Default left. |
{ "value": string, "style"?: Style }— an interpolatable, styled text run.{ "pageNumber": true }— the current page number.{ "totalPages": true }— the total page count.
spacer
A fixed vertical gap. → guide: Layout| Field | Type | Required | Notes |
|---|---|---|---|
size | number (points) | yes | Must be ≥ 0. |
image
A base64-embedded image. → guide: Images| Field | Type | Required | Notes |
|---|---|---|---|
base64 | string | yes | Raw base64 bytes, no data: prefix. Renderer sniffs the format (PNG, JPEG, …). |
fit | "fitArea" · "fitWidth" · "fitHeight" | no | Default fitArea. |
table
A grid with an optional header and aforEach-driven body. → guide: Tables
| Field | Type | Required | Notes |
|---|---|---|---|
columns | Column[] | yes | At least one. Each is {kind:"relative",weight} or {kind:"constant",width}. |
header.row | Cell[] | no | Length must equal columns.length. |
body.forEach | string | yes | Dotted path into data resolving to a JSON array. |
body.row | Cell[] | yes | Row template. Length must equal columns.length. |
body.row, the current array element binds to item — e.g. {{item.description}}. A Cell is:
| Field | Type | Required | Notes |
|---|---|---|---|
child | Node | yes | |
border.bottom | number (points) | no | Bottom-rule thickness. (Top/left/right deferred.) |
border.color | Color | no | Default black; drawn only when bottom is set. |
padding | number (points) | no | Vertical padding inside the cell. |
Style
style merges over page.defaultTextStyle. fontSize must be > 0 when present. → guide: Text & styling
Colours
A colour is either:- a hex string —
#RGB,#RRGGBB, or#AARRGGBB(3, 6, or 8 hex digits; case-insensitive — in the 8-digit form the alpha byte comes first, ARGB), or - a dotted palette token (case-insensitive) from QuestPDF’s Material palette —
"grey.darken1","blue.medium","red.lighten2","indigo.darken3".
422. Full palette: the QuestPDF colour reference. → guide: Text & styling
Interpolation
Tokens are{{path.to.value}}; whitespace inside the braces is allowed.
- Paths resolve against
data—{{customer.name}},{{invoice.total}}. - Inside
table.body.forEach, the element binds toitem—{{item.unitPrice}}. - No array indexing (
lines[0].name) and no expressions. Put computed values indata. - A missing path fails with
422and lists every missing path.
Errors
All failures are RFC 7807 Problem Details (application/problem+json) with a top-level errors array of { code, message }. Full reference: Errors.
| Status | When |
|---|---|
400 | JSON is malformed, has wrong field types or a missing/unknown node "type", or omits template. |
422 | Well-formed JSON but unusable: structurally invalid (missing page.content, a header/body cell count that doesn’t match columns, an unknown size/enum/colour), too complex (over the 10,000-node cap), an interpolation token with no matching data, or a render failure (an element that can’t fit, a layout that won’t converge, or undecodable content such as a bad base64 image). |
500 | A genuinely unexpected server failure. Render problems are surfaced as 422, so a 500 here is rare and should be treated as a bug. |
422 codes:
code | Meaning |
|---|---|
template.missing_data | One per missing data path; the path is in message. |
template.‹path› | A structural problem at ‹path› (e.g. template.page.content.body.row; the 10,000-node cap reports template.page). |
template.too_complex | A render-time layout failure — an element can’t fit, or the layout won’t converge. |
template.invalid_content | Content the renderer couldn’t process (e.g. an invalid base64 image). |
Out of scope (v1)
Not supported, by design — deferred to a later schema, gated by customer demand:- QR codes, barcodes, signature fields, charts
- Custom font upload, URL-sourced images
- Expressions, math, formatting helpers (
{{currency total}}) - Loops or conditionals outside
table.body.forEach - Saved/named templates — every request inlines its template
- Array indexing in paths (
lines[0]) and a literal{{in output