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:
{
"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
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
import json
template = client.templates.create(json.load(open("price-card.json")))
print(template["id"], template["version"])CLI
imagestep template create -f price-card.json -o jsoncurl
curl -s https://api.imagestep.dev/api/v1/templates -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -d @price-card.jsonThe 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.
{
"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 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
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
png = client.images.render("price-card@1", {"title":"Casa bottle","price":"$39"}) # "@1" pins the version
open("card.png", "wb").write(png)CLI
imagestep image render --template price-card@1 --data '{"title":"Casa bottle","price":"$39"}' --out card.pngcurl
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.pngNo 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
// 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
# 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
imagestep jobs submit --op render_template --template-id builtin-template-og-image --items '[{"title":"Hello"}]' --waitcurl
# "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
# tool call
transform {"op":"render_template","parameters":{"templateId":"builtin-template-og-image","items":[{"title":"Hello"}]}}n8n
{
"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 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:
{
"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: 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:
PUT /templates/{id}never edits in place: it saves versionn+1, andid@nstays 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 —DELETEtakes the template and every version.- Reference a version anywhere a template is named —
templateIdon a render or a job,GET /templates/{id}— asid@versionorslug@version. Without@, the current one. A template or version that does not exist is404 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) andbuiltin-template-og-image(1200×630; tag, title, description, site). They are version 1 and read-only (403onPUTandDELETE). To start from one, read it and create your own from itshtml,css,widthandheight— leave its slug behind, sincebuiltin-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:
id@version— resolved by the service — the worker gets the frozen bytes, never a lookuptemplate + one row— every placeholder filled from the rowChromium— no JavaScript, no network but the allow-list, a fresh context, 20 sPNG— exactly the declared size- then:
in a job— a new asset per rowsynchronously— 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 itspublicUrlis 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:
| 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) — 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 | 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 | 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} | 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 | client.templates.versions(id)client.templates.versions(template_id) | template versions <id> | every saved version, newest first |
| POST /templates | client.templates.create(template)client.templates.create(template) | template create | create version 1 |
| 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} | client.templates.delete(id)client.templates.delete(template_id) | template delete <id> | delete every version; built-ins answer 403 |
| 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.