A template is a JSON document: the same one the visual designer saves. You can keep it in git, edit it by hand or with an agent, and send it back, and the designer shows your change. Nothing converts it: what you read is what you write.
The document
GET /v1/templates/{id} returns the template with its document:
{
"data": {
"id": "0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"name": "Weekly digest",
"schemaVersion": 1,
"blocks": [
{ "id": "hero", "type": "heading", "text": "Hi {{first_name|there}}" },
{ "id": "cta", "type": "button", "text": "Read more", "href": "https://juniperkitchen.com/journal" },
{ "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
],
"theme": {},
"language": "en",
"plainTextOverride": null,
"transactionalDefaults": {
"subject": "This week at {{ org.name }}",
"previewText": null,
"from": null,
"replyTo": null
},
"alias": null,
"publishedVersion": null,
"hasUnpublishedChanges": true,
"revision": 7,
"thumbnailUrl": null,
"tags": [],
"createdAt": "2026-10-01T09:30:00.000Z",
"updatedAt": "2026-10-02T14:05:00.000Z"
}
}blocksis the email, top to bottom. Each block has a uniqueidand atype; the API reference lists every block type as its own schema (HeadingBlock,ButtonBlock,FooterBlockand the rest, underEmailBlock) with its fields. A block of any othertypeis refused with 400. A footer is required: afooterblock at the top level (not inside a section), withlocked: trueand its text, usually your name and postal address. Without it the template does not render.themeholds only what this email overrides of the workspace’s brand kit (getBrandKit);{}follows it.transactionalDefaultsis what a transactional send of the template uses unless the send gives its own: the subject, preview line, sender and reply-to. Campaigns and journeys copy the design and set their own.- Keep fields you do not recognise inside a block. The format is versioned
(
schemaVersion) and grows by adding fields, so a tool that drops unknown ones loses data. - Merge tags work in any text, the subject and the preview line, in either spelling:
{{first_name|there}}or{{ contact.firstName | default: "there" }}. POST /v1/templates/renderdraws a document without saving it, with its warnings: a quick check before you write.POST /v1/templates/{id}/renderdraws a saved one: its published version (what sends use), its draft, or an older version.
An HTML template
To send HTML you already have, make it one html block followed by the footer. For
transactional mail (a receipt, a password reset), set unsubscribeLink: false on the
footer:
{
"name": "Password reset",
"alias": "password-reset",
"blocks": [
{ "id": "body", "type": "html", "text": "<h1>Reset your password</h1><p>…</p>" },
{ "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true, "unsubscribeLink": false }
],
"transactionalDefaults": { "subject": "Reset your password" }
}The HTML is sent as written, not checked or inlined. Campaigns and journeys refuse
html blocks, so a template like this serves transactional sends only.
Writing it back
PATCH /v1/templates/{id} with the fields you change and the revision you read as
expectedRevision:
{
"blocks": [
{ "id": "hero", "type": "heading", "text": "Hi {{first_name|there}}" },
{ "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
],
"expectedRevision": 7
}If someone saved in between, the response is 409 REVISION_CONFLICT, whose details carry
the current revision, updatedAt and who changed it; read again, merge, and retry.
Without expectedRevision, the last write wins.
Saving changes the draft. Transactional sends that name the template in email.template
use the published version until
someone publishes (POST /v1/templates/{id}/publish), which an agent key cannot do.
A published version also keeps the brand kit and shared blocks as they were, so
after a shared footer or the brand changes, hasUnpublishedChanges turns true and
publishing again brings the change to sends.
Sending a test
POST /v1/templates/test-send sends a design to check how it looks, and records nothing
as a message. It needs templates:write and an integration or partner key; an
agent key is refused with 403, so a person sends an agent’s tests. A key sends to one
workspace member who has accepted their invitation, once per call. An embed session can
send only to the addresses its testSendTo names, and cannot send when that list is
empty. The test comes from a verified sending domain.
From the CLI
kirora templates get 0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f > weekly-digest.json
# edit .data.blocks, then send the document and its revision back
jq '{blocks: .data.blocks, theme: .data.theme, expectedRevision: .data.revision}' \
weekly-digest.json > update.json
kirora templates update 0192f3d0-1a2b-7c3d-8e4f-5a6b7c8d9e0f -d @update.jsonFrom an agent
The MCP server’s read_api and write_api tools reach the same two operations
(getTemplate, updateTemplate), so an agent reads a template, edits its blocks and
writes them back with expectedRevision. An agent’s edit leaves the template waiting for a person to
publish it; see Agents.