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

# Templates

For anything with a layout — an OG image per blog post, a quote card, a price tag, a certificate — an image op is the wrong tool and a template is the right one: HTML and CSS with `{{ variables }}`, saved and versioned, rendered to a PNG at a fixed size. One row of data makes one image; five hundred rows make five hundred, as one job.

## What a template is

A template is a document — save it as `price-card.json`:

```json
{
  "name": "Price card",
  "slug": "price-card",
  "html": "<div class=\"card\"><h1>{{ title }}</h1><p class=\"price\">{{ price }}</p></div>",
  "css": ".card { width: 1200px; height: 630px; display: grid; place-content: center; font-family: sans-serif } .price { font-size: 96px }",
  "width": 1200,
  "height": 630,
  "variables": [
    "title",
    "price"
  ]
}
```

Create it with `POST /api/v1/templates`. There is no MCP tab: an agent renders templates, a person writes them.

#### JavaScript

```js
import { readFile } from "node:fs/promises";

const template = await client.templates.create(JSON.parse(await readFile("price-card.json", "utf8")));
console.log(template.id, template.version);
```

#### Python

```python
import json

template = client.templates.create(json.load(open("price-card.json")))
print(template["id"], template["version"])
```

#### CLI

```sh
imagestep template create -f price-card.json -o json
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/templates -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -d @price-card.json
```

The answer is the template as stored — `201`, version 1. `name`, `html`, `width` and `height` are required, and anything outside the limits below is `400 invalid_param` naming the field. An account keeps up to 200 templates of its own; one more is `422 resource_limit_exceeded`.

```json
{
  "id": "tpl_bd199fc7215e449c8738fa8cf01765f4",
  "name": "Price card",
  "slug": "price-card",
  "html": "<div class=\"card\"><h1>{{ title }}</h1><p class=\"price\">{{ price }}</p></div>",
  "css": ".card { width: 1200px; height: 630px; display: grid; place-content: center; font-family: sans-serif } .price { font-size: 96px }",
  "width": 1200,
  "height": 630,
  "variables": [
    "title",
    "price"
  ],
  "version": 1,
  "createdAt": "2026-09-17T15:41:47.121558596Z",
  "updatedAt": "2026-09-17T15:41:47.121558596Z"
}
```

| field | type | meaning |
| --- | --- | --- |
| name · description | `string` | for people; neither changes what renders, though editing one is a new version like any PUT |
| slug | `string` | optional — made from the name, with -2, -3… when that one is taken. It names the template in a call wherever the id does; builtin- is reserved, and one you send that is already taken is 400 |
| id | `string` | the id the service gave it; id@version addresses one frozen version |
| html | `string` | up to 256 KB. {{ var }} is HTML-escaped on the way in; {{{ var }}} is inserted raw, for markup you generate yourself |
| css | `string` | up to 128 KB, inlined into the page. A {{ var }} value here is filtered rather than escaped: angle brackets, braces, backslashes, backticks and comment marks are removed, so a value cannot close the stylesheet |
| width · height | `integer` | 1–4096 px each: the PNG is exactly this size, whatever the content does |
| variables | `string[]` | up to 100 names (letters, digits, _ and .); left out, the {{ … }} names found in html and css. They say which keys a row should carry — a render does not check a row against them: every placeholder is filled from the row, a.b walks nested data, a missing value renders as the empty string and a row's extra keys are ignored |
| version | `integer` | starts at 1, and every PUT adds one — a rename included |
| builtIn | `boolean` | true for the templates ImageStep ships; those are read-only |
| createdAt · updatedAt | `string (date-time)` | when it was first saved and last changed |

## Rendering one image

`POST /api/v1/images/render` takes a template reference and one row of variables and answers with the PNG bytes — synchronous, nothing stored, 20 seconds at most. Like every [synchronous call](https://base_url.placeholder/docs/sync) it counts one against the processing allowance — free on a paid plan, paid from the balance past the allowance on Free — and shares the synchronous endpoints' per-account concurrency.

#### JavaScript

```js
import { writeFile } from "node:fs/promises";

const png = await client.images.render("price-card@1", {"title":"Casa bottle","price":"$39"}); // "@1" pins the version
await writeFile("card.png", png);
```

#### Python

```python
png = client.images.render("price-card@1", {"title":"Casa bottle","price":"$39"})  # "@1" pins the version
open("card.png", "wb").write(png)
```

#### CLI

```sh
imagestep image render --template price-card@1 --data '{"title":"Casa bottle","price":"$39"}' --out card.png
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/images/render -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"templateId":"price-card@1","data":{"title":"Casa bottle","price":"$39"}}' -o card.png
```

No MCP tab: over MCP a render is always a job, even for one row, because the tool answers with an asset and a URL rather than bytes — it is the call below with one item.

## Rendering a batch

The `render_template` op is the one op that takes no assets: its input is the template plus `items`, one object of variables per output, up to 500. It submits as a job with one item per row, and each becomes a PNG asset with `source: render`. The Free plan counts render items against its monthly allowance and pays for the rows past it from your balance; paid plans are unlimited. A batch is refused before anything runs only when it cannot fit: `402 insufficient_credit` when the balance cannot cover the rows past the allowance, `422 asset_count_exceeded` past the assets your plan may still store.

#### JavaScript

```js
// pnpm add imagestep
const job = await client.ops.run("render_template", { templateId: "builtin-template-og-image", items: [{ title: "Hello" }], wait: true });
const [out] = await client.jobs.outputs(job);
```

#### Python

```python
# pip install imagestep
job = client.ops.run("render_template", template_id="builtin-template-og-image", items=[ {"title": "Hello"}], wait=True)
[out] = client.jobs.outputs(job)
```

#### CLI

```sh
imagestep jobs submit --op render_template --template-id builtin-template-og-image --items '[{"title":"Hello"}]' --wait
```

#### curl

```sh
# "wait" holds the response until the job is done (60 s at most): 200 with the finished job,
# or 202 with the handle — then GET /api/v1/jobs/<id>?wait=30 waits again
curl -sS -X POST https://api.imagestep.dev/api/v1/jobs \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"op":"render_template","templateId":"builtin-template-og-image","items":[{"title":"Hello"}],"wait":30}'
```

#### MCP

```text
# tool call
transform  {"op":"render_template","parameters":{"templateId":"builtin-template-og-image","items":[{"title":"Hello"}]}}
```

#### n8n

```json
{
  "nodes": [
    {
      "parameters": {
        "resource": "op",
        "operation": "run",
        "op": "render_template",
        "inputMode": "none",
        "parameters": "{\"templateId\":\"builtin-template-og-image\",\"items\":[{\"title\":\"Hello\"}]}",
        "storeResult": true
      },
      "name": "ImageStep",
      "type": "n8n-nodes-imagestep.imageStep",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    }
  ],
  "connections": {}
}
```

The [n8n](https://base_url.placeholder/docs/n8n) tab is a workflow to paste onto the canvas: the node's _Op_ set to `render_template`. A real batch keeps its rows in a file — the CLI takes `--items ./rows.json`, and `items` is an ordinary array everywhere else. With `?dryRun=true` (`dryRun` / `dry_run` in the SDKs and MCP, `jobs estimate` in the CLI) the same call answers with the count and what is left of the allowance, and creates nothing — two rows:

```json
{
  "type": "render",
  "templateId": "builtin-template-og-image",
  "templateName": "OG image",
  "templateVersion": 1,
  "totalItems": 2,
  "costPerItem": 0,
  "estimatedCredits": 0,
  "creditBalance": 0,
  "sufficientCredit": true,
  "assetCountLeft": 199,
  "processCountLeft": 199,
  "overageRuns": 0,
  "overageCredits": 0
}
```

`templateVersion` is the version the job would render — the one `id@version` pinned, or the current one. The job records the same three fields, and each output's `lineage` its `templateId` and `templateVersion`. The outputs are ordinary [assets](https://base_url.placeholder/docs/assets): publish them for URLs, or download the bytes. A row that cannot render fails only its own item, and why it failed decides whether a resume can help.

## Versions and built-ins

A `PUT` carries only what changes: the service merges the body over the current version, so what you leave out is carried over and two fields are a whole call. What you do send is checked like a create; `""` empties `css` or `description`. Leave `variables` out and they stay as they are — unless `html` or `css` changes, when they are read again from the new markup. Every `PUT` is a new version, a rename included, and the old one stays readable and renderable:

#### JavaScript

```js
const next = await client.templates.update("price-card", { width: 1080, height: 1080 }); // version 2
const before = await client.images.render("price-card@1", {"title":"Casa bottle","price":"$39"}); // still 1200 × 630
```

#### Python

```python
next = client.templates.update("price-card", {"width": 1080, "height": 1080})  # version 2
before = client.images.render("price-card@1", {"title":"Casa bottle","price":"$39"})  # still 1200 × 630
```

#### CLI

```sh
imagestep template update price-card --width 1080 --height 1080 -o json
imagestep image render --template price-card@1 --data '{"title":"Casa bottle","price":"$39"}' --out card-v1.png
```

#### curl

```sh
curl -s -X PUT https://api.imagestep.dev/api/v1/templates/price-card -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -d '{"width": 1080, "height": 1080}'
curl -s https://api.imagestep.dev/api/v1/images/render -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"templateId":"price-card@1","data":{"title":"Casa bottle","price":"$39"}}' -o card-v1.png
```

- `PUT /templates/{id}` never edits in place: it saves version `n+1`, and `id@n` stays readable and renderable, so a job pinned to a version keeps rendering the bytes it was priced on. Nothing caps the history, and no single version can be deleted — `DELETE` takes the template and every version.
- Reference a version anywhere a template is named — `templateId` on a render or a job, `GET /templates/{id}` — as `id@version` or `slug@version`. Without `@`, the current one. A template or version that does not exist is `404 not_found`.
- The built-ins ship with the service, each with the variables its rows take: `builtin-template-social-card` (1080×1080; headline, subtitle, brand, handle), `builtin-template-quote-card` (1080×1350; quote, author, brand) and `builtin-template-og-image` (1200×630; tag, title, description, site). They are version 1 and read-only (`403` on `PUT` and `DELETE`). To start from one, read it and create your own from its `html`, `css`, `width` and `height` — leave its slug behind, since `builtin-` is reserved (an import drops it for you).

## What a template can and cannot do

Templates are customer HTML, so they render in a separate Chromium worker under rules that make them safe to run and cheap to reason about:

1. `id@version` — resolved by the service — the worker gets the frozen bytes, never a lookup
2. `template + one row` — every placeholder filled from the row
3. `Chromium` — no JavaScript, no network but the allow-list, a fresh context, 20 s
4. `PNG` — exactly the declared size
5. then:

   - `in a job` — a new asset per row
   - `synchronously` — the bytes in the answer, nothing stored

- **No JavaScript.** Scripts do not run. Layout is CSS; anything computed is computed in the row you send.
- **No network,** except `data:` URLs and ImageStep's own asset host over https. Publish an asset and its `publicUrl` is usable in an `<img>`; a third-party URL — or a redirect to one — is blocked before a connection opens and renders as a missing image, not as an error.
- **Fonts on board:** Liberation, FreeFont, Noto Color Emoji, WenQuanYi Zen Hei and IPA Gothic for Chinese and Japanese, TLWG Loma for Thai, and Unifont as the last resort. Anything else you inline as a `data:` URL in an `@font-face`.
- **A fresh context per render, 20 seconds at most,** counted from when it starts rendering, not while it waits its turn.
- **Exactly the declared size.** The PNG is the viewport; whatever lies outside it is not in the picture, so design for the box.

In a batch, a row that fails says why in its item — [what a failed item looks like](https://base_url.placeholder/docs/jobs#failure):

| what went wrong | the item | what to do |
| --- | --- | --- |
| the render ran past 20 s, or Chromium could not load the document | FAILED · invalid_param · retryable: false | fix the template or the row and submit again — the same bytes fail the same way, and a resume re-renders the version the job pinned |
| the worker crashed, or storing the PNG failed | retried by ImageStep; after the last attempt FAILED · internal_error · retryable: true | resume the job: only the failed rows run again |
| an image or font off the allow-list | COMPLETED — that resource is simply missing | inline it, or publish it as an asset |

The synchronous render answers a run past the deadline the way every synchronous call does — `503 provider_unavailable` with `details.reason` `deadline_exceeded` ([limits](https://base_url.placeholder/docs/sync#limits)) — and a document Chromium could not load the way a batch does: `400 invalid_param` on `templateId`, not retryable.

## Managing templates

| REST | SDK — JavaScript · Python | CLI | what |
| --- | --- | --- | --- |
| [GET /templates](https://base_url.placeholder/docs/api/templates#get-templates) | `client.templates.list(filter, { page, perPage, cursor })``client.templates.list(filter=, **params)` | `template list` | one page, built-ins first, then yours — rows without html and css; get one for the whole document |
| [GET /templates](https://base_url.placeholder/docs/api/templates#get-templates) | `client.templates.iterate(filter)``client.templates.iterate(filter=)` | `template list` | every page; the CLI always reads them all, and -o json prints full documents |
| [GET /templates/{id}](https://base_url.placeholder/docs/api/templates#get-templates-id) | `client.templates.get(id)``client.templates.get(template_id)` | `template get <id>` | one template, or one version of it (id@version) |
| [GET /templates/{id}/versions](https://base_url.placeholder/docs/api/templates#get-templates-id-versions) | `client.templates.versions(id)``client.templates.versions(template_id)` | `template versions <id>` | every saved version, newest first |
| [POST /templates](https://base_url.placeholder/docs/api/templates#post-templates) | `client.templates.create(template)``client.templates.create(template)` | `template create` | create version 1 |
| [PUT /templates/{id}](https://base_url.placeholder/docs/api/templates#put-templates-id) | `client.templates.update(id, template)``client.templates.update(template_id, template)` | `template update <id>` | a new version from only what changes; what the body leaves out carries over |
| [DELETE /templates/{id}](https://base_url.placeholder/docs/api/templates#delete-templates-id) | `client.templates.delete(id)``client.templates.delete(template_id)` | `template delete <id>` | delete every version; built-ins answer 403 |
| [POST /templates/import](https://base_url.placeholder/docs/api/templates#post-templates-import) | `client.templates.import(templates)``client.templates.import_(templates)` | `template import <file>` | each document becomes version 1, its slug made from the name |

The SDK column is both SDKs, JavaScript above Python. Every write takes an `Idempotency-Key`; the SDKs and the CLI send one for you. MCP has no template management: an agent renders with `transform`. A worked OG-image workflow for a content site — rows from a sheet, one PNG each, URLs written back — is one of the [n8n templates](https://base_url.placeholder/docs/n8n).
