> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tipar.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Template schema

> The complete v1 template schema — every node, field, and value, in one place.

This is the exhaustive reference for the template JSON. For a gentler walk-through, start with [Template basics](/templates/overview) and the guides; come here for the precise contract.

<Note>
  **Status:** the v1 schema is implemented and live behind both [`/generate`](/api-reference/generate) and the [playground](/api-reference/playground). It's pre-1.0 and may still grow (additively); it isn't frozen as a versioned public contract yet.
</Note>

Two principles bound the design: **no expressions** (only `{{path}}` substitution — compute values in your code) and **nodes mirror layout, not meaning** (`column`, `row`, `text`, … — not `invoice`, `lineItem`). See [Template basics](/templates/overview#two-rules).

## Request envelope

```json theme={"dark"}
POST /generate
Authorization: Bearer tipar_live_<key>
Content-Type: application/json

{
  "template": { "page": { … } },
  "data":     { … }
}
```

Success is `200 application/pdf`. Failures are [Problem Details](#errors).

## page

```json theme={"dark"}
{
  "page": {
    "size": "A4",
    "margin": 30,
    "defaultTextStyle": { "fontSize": 10 },
    "header":  "Node | null",
    "content": "Node",
    "footer":  "Node | null"
  }
}
```

| 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](#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](#nodes)                           | no       | Repeats on every page.                                                                                                                  |
| `content`          | [Node](#nodes)                           | **yes**  | The page body.                                                                                                                          |
| `footer`           | [Node](#nodes)                           | no       | Repeats on every page.                                                                                                                  |

## Nodes

Every node has a `"type"` discriminator. There are seven types.

### column

Vertical stack. → guide: [Layout](/templates/layout#column)

```json theme={"dark"}
{ "type": "column", "spacing": 8, "items": ["Node", "Node"] }
```

| 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](/templates/layout#row)

```json theme={"dark"}
{
  "type": "row",
  "items": [
    { "size": { "kind": "relative", "weight": 1 }, "child": "Node" },
    { "size": { "kind": "constant", "width": 180 }, "child": "Node" }
  ]
}
```

| 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](/templates/text#text)

```json theme={"dark"}
{ "type": "text", "value": "Invoice {{invoice.number}}", "style": { "fontSize": 18, "bold": true }, "align": "right" }
```

| Field   | Type                              | Required | Notes                                                |
| ------- | --------------------------------- | -------- | ---------------------------------------------------- |
| `value` | string                            | **yes**  | Supports `{{path}}` [interpolation](#interpolation). |
| `style` | [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](/templates/text#richtext)

```json theme={"dark"}
{
  "type": "richText",
  "align": "center",
  "spans": [
    { "value": "Page " },
    { "pageNumber": true },
    { "value": " of " },
    { "totalPages": true }
  ]
}
```

| Field   | Type                              | Required | Notes                                    |
| ------- | --------------------------------- | -------- | ---------------------------------------- |
| `spans` | Span\[]                           | **yes**  | At least one. Rendered inline, in order. |
| `align` | `"left"` · `"center"` · `"right"` | no       | Default `left`.                          |

A **Span** is one of:

* `{ "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](/templates/layout#spacer)

```json theme={"dark"}
{ "type": "spacer", "size": 12 }
```

| Field  | Type            | Required | Notes        |
| ------ | --------------- | -------- | ------------ |
| `size` | number (points) | **yes**  | Must be ≥ 0. |

### image

A base64-embedded image. → guide: [Images](/templates/images)

```json theme={"dark"}
{ "type": "image", "base64": "iVBORw0KGgo…", "fit": "fitArea" }
```

| Field    | Type                       | Required | Notes                                                                                                   |
| -------- | -------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `base64` | string                     | **yes**  | Raw base64 bytes, **no** `data:` prefix. Renderer sniffs the format (PNG, JPEG, …).                     |
| `fit`    | `"fitArea"` · `"fitWidth"` | no       | Default `fitArea`. There is no `fitHeight` — see [Images](/templates/images#controlling-size-with-fit). |

### table

A grid with an optional header and a `forEach`-driven body. → guide: [Tables](/templates/tables)

```json theme={"dark"}
{
  "type": "table",
  "columns": [
    { "kind": "relative", "weight": 5 },
    { "kind": "constant", "width": 80 }
  ],
  "header": { "row": ["Cell", "Cell"] },
  "body": { "forEach": "invoice.lines", "row": ["Cell", "Cell"] }
}
```

| 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`.                          |

Inside `body.row`, the current array element binds to `item` — e.g. `{{item.description}}`. A **Cell** is:

```json theme={"dark"}
{ "child": "Node", "border": { "bottom": 1, "color": "grey.darken1" }, "padding": 4 }
```

| Field           | Type              | Required | Notes                                             |
| --------------- | ----------------- | -------- | ------------------------------------------------- |
| `child`         | Node              | **yes**  |                                                   |
| `border.bottom` | number (points)   | no       | Bottom-rule thickness. (Top/left/right deferred.) |
| `border.color`  | [Color](#colours) | no       | Default black; drawn only when `bottom` is set.   |
| `padding`       | number (points)   | no       | Vertical padding inside the cell.                 |

## Style

```json theme={"dark"}
{ "fontSize": 10, "bold": true, "semiBold": false, "italic": false, "color": "#666666" }
```

All fields optional. A node's `style` merges over `page.defaultTextStyle`. `fontSize` must be > 0 when present. → guide: [Text & styling](/templates/text#style)

## 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"`.

The resolver walks the palette path against QuestPDF's colour set; an unknown token fails validation with `422`. Full palette: the [QuestPDF colour reference](https://www.questpdf.com/api-reference/colors.html). → guide: [Text & styling](/templates/text#colours)

## 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 to `item` — `{{item.unitPrice}}`.
* **No** array indexing (`lines[0].name`) and **no** expressions. Put computed values in `data`.
* A missing path fails with `422` and lists every missing path.

→ guide: [Template basics](/templates/overview#interpolation)

## Errors

All failures are RFC 7807 Problem Details (`application/problem+json`) with a top-level `errors` array of `{ code, message }`. Full reference: [Errors](/api-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).                                                  |

Example (missing data):

```json theme={"dark"}
{
  "title": "Template references missing data",
  "status": 422,
  "errors": [
    { "code": "template.missing_data", "message": "brand.name" },
    { "code": "template.missing_data", "message": "invoice.number" }
  ]
}
```

Example (structural):

```json theme={"dark"}
{
  "title": "Template is structurally invalid",
  "status": 422,
  "errors": [
    { "code": "template.page.content.body.row", "message": "body has 2 cell(s), columns has 3" }
  ]
}
```

## 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
