---
title: Templates
url: https://base_url.placeholder/docs/api/templates
group: Surfaces
---

# Templates

HTML/CSS render templates for the render_template op: create, version, import/export

7 endpoints under `/api/v1/templates`. What every call shares is on [the REST overview](https://base_url.placeholder/docs/api#conventions), and what they are for — with the calls for every SDK, the CLI and MCP — on [Templates](https://base_url.placeholder/docs/templates). `*` marks a required field.

- GET /api/v1/templates — List templates
- POST /api/v1/templates — Create a template
- POST /api/v1/templates/import — Import templates
- GET /api/v1/templates/{id} — Get a template (or one frozen version of it)
- PUT /api/v1/templates/{id} — Update a template (new version)
- DELETE /api/v1/templates/{id} — Delete a template
- GET /api/v1/templates/{id}/versions — List a template's versions

## List templates

GET /api/v1/templates

[Paged](https://base_url.placeholder/docs/errors#pagination) — `page` and `perPage`, or `cursor`, in; `meta` out

Built-in templates first, then the caller's own, most recently updated first, a page at a time. Each row is the current version without its html and css — read one with GET /api/v1/templates/{id}, which is also the document POST /api/v1/templates/import takes back.

FILTER: omit for both, `builtin` for the shipped ones only, `user` for your own only.

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| filter | query | string | 'builtin' or 'user'; omit for both<br>one of builtin · user |

### Returns `200` — `data` is `TemplateSummary[]`

One page of templates; `meta` says whether there is another

### Errors

| Status | Code and when |
| --- | --- |
| 400 | - `invalid_param` — `filter` is neither `builtin` nor `user`<br>- `invalid_param` on `cursor` — not a cursor this listing issued, or sent with `page` |

## Create a template

POST /api/v1/templates

Accepts an [`Idempotency-Key`](https://base_url.placeholder/docs/errors#idempotency)

Creates version 1 and answers `201` with it. `name`, `html`, `width` and `height` are required. `html` ≤ 256 KB and `css` ≤ 128 KB (UTF-8 bytes), `width` / `height` 1–4096 px; anything outside is `400 invalid_param` naming the field. `variables` defaults to the `{{ … }}` names found in the html and css. Each account may own at most 200 templates (`422 resource_limit_exceeded`).

Rendering is `POST /api/v1/jobs {"op": "render_template", "templateId": …, "items": [...]}`, or one row now with `POST /api/v1/images/render`.

### Request body — `TemplateData`

### Returns `201` — `data` is `TemplateData`

The template, at version 1

### Errors

| Status | Code and when |
| --- | --- |
| 400 | - `invalid_param` on `name` — missing, or over 200 characters<br>- `invalid_param` on `html` / `css` — `html` missing, or either over its limit<br>- `invalid_param` on `width` / `height` — missing, or outside 1–4096<br>- `invalid_param` on `variables` — more than 100, or a name that is not an identifier (letters, digits, `_` and `.`)<br>- `invalid_param` — the `slug` you sent is malformed, starts with `builtin-`, or is already one of yours |
| 422 | `resource_limit_exceeded` — the account already owns 200 templates |

## Import templates

POST /api/v1/templates/import

Accepts an [`Idempotency-Key`](https://base_url.placeholder/docs/errors#idempotency)

Takes a list of template documents — what GET /api/v1/templates/{id} returns, one per template — and creates each as a new template at version 1: history and slug are not carried over (the slug is made from the name, with a numeric suffix when it is taken). The list rows of GET /api/v1/templates carry no html, so they are not an export.

Each entry is checked like a create; one that fails is reported in `errors` and the rest still land, so a `201` can carry refusals — read `errors`. The whole import is refused (`422 resource_limit_exceeded`) if it would take the account past 200 templates.

### Request body — `TemplateData[]`

### Returns `201` — `data` is `BatchImportResultDTOTemplateData`

What landed, and one line per entry that did not

### Errors

| Status | Code and when |
| --- | --- |
| 400 | `invalid_param` — the body is not a non-empty list |
| 422 | `resource_limit_exceeded` — the entries would take the account past 200 templates; nothing was imported |

## Get a template (or one frozen version of it)

GET /api/v1/templates/{id}

`{id}` is a template id (or your own slug) for the current version, or `{id}@{version}` for one specific version — the same spelling `render_template` accepts as `templateId`, so what a job rendered with can always be read back. Built-ins have exactly one version.

An earlier version comes back as a document whose `id` is `{id}@{version}`, without `slug`, `description` or `updatedAt` — those belong to the template, not to a version of it.

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| id* | path | string | Template id, slug, or id@version |

### Returns `200` — `data` is `TemplateData`

The template document

### Errors

| Status | Code and when |
| --- | --- |
| 400 | `invalid_param` on `templateId` — the `@version` is not a positive number |
| 404 | `not_found` on `templateId` — no such template or version, or it is not yours |

## Update a template (new version)

PUT /api/v1/templates/{id}

Accepts an [`Idempotency-Key`](https://base_url.placeholder/docs/errors#idempotency)

Send only what changes: the body is merged over the current version, and a field it leaves out keeps its value (`{"width": 1080, "height": 1080}` is a whole update); `""` empties `css` or `description`. `variables`, when not sent, stay as they are unless `html` or `css` changes, and are then derived from the new markup. Same limits as create. Never edits in place: the template moves to `version + 1` — every PUT does, a rename included — and the previous bytes stay readable as `{id}@{version}`, so a job or automation that pinned a version keeps rendering exactly that. Built-in ids are 403; to start from one, create your own with its html.

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| id* | path | string | Template id or your own slug |

### Request body — `TemplateData`

### Returns `200` — `data` is `TemplateData`

The template, at its new version

### Errors

| Status | Code and when |
| --- | --- |
| 400 | `invalid_param` naming the field — the merged document fails a create's check (see POST /api/v1/templates), or a new `slug` is malformed, reserved or taken |
| 403 | `forbidden` — a built-in template |
| 404 | `not_found` on `templateId` — no such template, or it is not yours (a path carrying `@version` names none: a version cannot be edited) |

## Delete a template

DELETE /api/v1/templates/{id}

Accepts an [`Idempotency-Key`](https://base_url.placeholder/docs/errors#idempotency)

Deletes the template and every version of it. A render job already rendering keeps the bytes it was handed; one still queued behind the account's other jobs fails unstarted (`invalid_state`). Built-in ids are 403.

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| id* | path | string | Template id or your own slug |

### Returns `204` — no body

Deleted; no body

### Errors

| Status | Code and when |
| --- | --- |
| 403 | `forbidden` — a built-in template |
| 404 | `not_found` on `templateId` — no such template, or it is not yours |

## List a template's versions

GET /api/v1/templates/{id}/versions

Every version ever saved, newest first, each as a full document — html and css included — whose `id` is `{id}@{version}` and which has no `slug`, `description` or `updatedAt`. Not paged: a template's history is one answer. A built-in answers with its one current document.

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| id* | path | string | Template id or your own slug — not `id@version` |

### Returns `200` — `data` is `TemplateData[]`

Every version, newest first

### Errors

| Status | Code and when |
| --- | --- |
| 404 | `not_found` on `templateId` — no such template, or it is not yours |

## Objects

Each described once; a linked type is another object on this page.

### BatchImportResultDTOTemplateData

What an import did: the entries that landed, and one line for each that did not

| Field | Type | Description |
| --- | --- | --- |
| data | TemplateData[] | The created resources, as saved, in the order sent |
| errors | string[] | One line per refused entry, naming its index and why (`Preset at index 2 'Hero': …`). Absent when every entry landed. |
| importedCount | integer (int32) | How many entries were created — the length of `data` |

### TemplateData

HTML/CSS render template with {{ var }} placeholders; the input of the render_template op

| Field | Type | Description |
| --- | --- | --- |
| builtIn | boolean | Whether this is a built-in template (read-only; PUT and DELETE are 403) |
| createdAt | string (date-time) | When the template — or, read as `id@version`, that version — was created |
| css | string | Stylesheet (≤ 128 KB as UTF-8). Like the html, it loads nothing but `data:` URIs and this product's own asset host; any other `@import` or `url()` is blocked at render time. |
| description | string | Free text for people; not rendered. `""` on a PUT empties it. |
| height | integer (int32) | Viewport / output height in px (required on create; 1–4096)<br>e.g. 1080 |
| html | string | Body HTML (required on create; ≤ 256 KB as UTF-8). Scripts never run; every request it makes is blocked at render time except `data:` URIs and this product's own asset host. |
| id | string | Template id (`tpl_…`; a built-in's is `builtin-template-…`). A version read by `id@version` carries that spelling here. |
| name | string | Display name (required on create; at most 200 characters)<br>e.g. "Quote card" |
| slug | string | URL-safe handle, unique per account: lowercase letters, digits and hyphens. Generated from the name when omitted; one you send must be free. `builtin-` is reserved. |
| updatedAt | string (date-time) | When the current version was saved; absent on a version read as `id@version` |
| variables | string[] | Placeholder names (at most 100, each letters, digits, `_` and `.`). Derived from the html/css when omitted. A render fills `{{ name }}` HTML-escaped and `{{{ name }}}` raw; a name the row does not carry renders as nothing. |
| version | integer (int32) | Current version; every PUT increments it and keeps the old one readable as `id@version` |
| width | integer (int32) | Viewport / output width in px (required on create; 1–4096)<br>e.g. 1080 |

### TemplateSummary

A template as it is listed: everything but its html and css, which GET /api/v1/templates/{id} returns

| Field | Type | Description |
| --- | --- | --- |
| builtIn | boolean | Whether this is a built-in template (read-only) |
| createdAt | string (date-time) | When the template was created |
| description | string | Free text for people |
| height | integer (int32) | Output height in px |
| id | string | Template id (`tpl_…`, or `builtin-template-…`); `id@version` addresses one frozen version |
| name | string | Display name |
| slug | string | URL-safe slug |
| updatedAt | string (date-time) | When its current version was saved — the order your own rows are listed in |
| variables | string[] | Placeholder names a render fills in |
| version | integer (int32) | Current version |
| width | integer (int32) | Output width in px |
