Skip to content

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.

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);

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.

{
  "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"
}
fieldtypemeaning
name · descriptionstringfor people; neither changes what renders, though editing one is a new version like any PUT
slugstringoptional — 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
idstringthe id the service gave it; id@version addresses one frozen version
htmlstringup to 256 KB. {{ var }} is HTML-escaped on the way in; {{{ var }}} is inserted raw, for markup you generate yourself
cssstringup 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 · heightinteger1–4096 px each: the PNG is exactly this size, whatever the content does
variablesstring[]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
versionintegerstarts at 1, and every PUT adds one — a rename included
builtInbooleantrue for the templates ImageStep ships; those are read-only
createdAt · updatedAtstring (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.

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);

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.

// 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);

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:

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
  • 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@versionresolved by the service — the worker gets the frozen bytes, never a lookup
  2. template + one rowevery placeholder filled from the row
  3. Chromiumno JavaScript, no network but the allow-list, a fresh context, 20 s
  4. PNGexactly the declared size
  5. then:
    • in a joba new asset per row
    • synchronouslythe 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:

what went wrongthe itemwhat to do
the render ran past 20 s, or Chromium could not load the documentFAILED · invalid_param · retryable: falsefix 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 failedretried by ImageStep; after the last attempt FAILED · internal_error · retryable: trueresume the job: only the failed rows run again
an image or font off the allow-listCOMPLETED — that resource is simply missinginline 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

RESTSDK — JavaScript · PythonCLIwhat
GET /templatesclient.templates.list(filter, { page, perPage, cursor })client.templates.list(filter=, **params)template listone page, built-ins first, then yours — rows without html and css; get one for the whole document
GET /templatesclient.templates.iterate(filter)client.templates.iterate(filter=)template listevery 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}/versionsclient.templates.versions(id)client.templates.versions(template_id)template versions <id>every saved version, newest first
POST /templatesclient.templates.create(template)client.templates.create(template)template createcreate 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/importclient.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.