Templates as codeMenu
On this page

Templates as code

Updated

Read, edit and write back a template's JSON document from git, the CLI or an agent.

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:

JSON
{
  "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"
  }
}
  • blocks is the email, top to bottom. Each block has a unique id and a type; the API reference lists every block type as its own schema (HeadingBlock, ButtonBlock, FooterBlock and the rest, under EmailBlock) with its fields. A block of any other type is refused with 400. A footer is required: a footer block at the top level (not inside a section), with locked: true and its text, usually your name and postal address. Without it the template does not render.
  • theme holds only what this email overrides of the workspace’s brand kit (getBrandKit); {} follows it.
  • transactionalDefaults is 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/render draws a document without saving it, with its warnings: a quick check before you write. POST /v1/templates/{id}/render draws 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:

JSON
{
  "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:

JSON
{
  "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

Terminal
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.json

From 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.

Next steps