---
title: "Templates"
description: "Every operation in this group: what it accepts, what it returns and the errors it can answer with."
url: "https://openemail.uk/docs/api/reference/templates"
area: "API"
category: "Reference"
---

# Templates

Every operation in this group: what it accepts, what it returns and the errors it can answer with.

## Operations

A body authored once and sent many times, scoped to the workspace rather than to whoever wrote it.

Two engines, for two different people. `blocks` is a validated tree whose node kinds are the exports of `@react-email/components`, checked at its input and rendered here. `html` is markup you rendered yourself (a react-email component run through `@react-email/render` in your own build, a design-tool export, a hand-written table layout), sanitised once when the version is published. We cannot execute your JSX: workerd has no `eval` and no transpiler, so the render happens where the component lives and what crosses the wire is HTML.

Versions are immutable once published, and that is the feature rather than bookkeeping. Editing a published template mints a new draft; live sends keep resolving the frozen version until somebody publishes again. Without it, renaming a slot in the editor would silently start sending mail with a blank where the order number was: no error, nothing in a log, and no way to recall it. With it, the published version keeps saying `orderId` and the caller finds out from a 422 on their own schedule.

`POST /emails` also takes a `template` field, so a caller already composing a message need not switch endpoints to reach a stored body.

### `GET /templates`

List templates

Each row carries the PUBLISHED version's `subject`, `engine`, `slots` and `props`, so a caller can see what to pass a template without a request per row. A template with nothing published carries none of them and reports `publishedVersion: null`.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Query parameters**

- `limit` (`integer`, at least 1, at most 100): Defaults to 25. The bound lives in the service, so it is not in the schema.
- `cursor` (`string`, 1 to 2048 characters): The `nextCursor` you were handed, passed back as it came and never one you build. It is opaque: it holds the `sort` it was handed out under and where the last template sat in that order, so a template deleted between pages never breaks the walk. Keyset on whichever `sort` you asked for, so a template edited mid-pagination under the default sort moves to the front and can be seen twice. A value this list did not hand out, or one handed out under another `sort`, is a 400 `invalid_cursor`.
- `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Narrows to drafts, active or archived templates. Omit for all three. `draft` here is the template's own status, not "has an unpublished draft", which every template has.
- `search` (`string`, up to 200 characters): Matches the name, the slug, the description, the published version's subject and the id. Every word has to appear, and each matches loosely: case, accents and separators are ignored and part of a longer word counts. When nothing matches exactly, close spellings are returned instead. `%` and `_` are taken literally rather than as wildcards, so pasting a name in cannot turn into a scan.
- `sort` (`string`, one of `"updated-newest"`, `"updated-oldest"`, `"created-newest"`, `"created-oldest"`, `"name"`, `"name-reversed"`): The order, and the field the cursor keys on. `updated-newest` is the default and the one the console lists under. Keep it the same while you page, because a cursor handed out under one sort is refused under another.

**Returns**

- `200` `TemplateList`: A page, most recently updated first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.list()`](https://openemail.uk/docs/sdk/reference/templates#list), [`templates.listAll()`](https://openemail.uk/docs/sdk/reference/templates#listAll), [`templates.iterate()`](https://openemail.uk/docs/sdk/reference/templates#iterate); CLI [`openemail templates list`](https://openemail.uk/docs/cli/reference/templates#templates-list); MCP [`listTemplates`](https://openemail.uk/docs/mcp/tools/templates#listTemplates).

### `POST /templates`

Create a template

Creates the template and its version 1. `publish: true` freezes that version there and then; without it you get a DRAFT, and a draft is not sendable. `POST /templates/{id}/send` answers `template_not_published` until somebody publishes it.

The engine follows what you send: `html` when you post `html`, `blocks` otherwise, and `engine` is only worth stating to be explicit about it. `document`, `slots` and `props` are unconstrained in this schema on purpose. One validator owns the block tree so that REST, tRPC and MCP cannot each hold a slightly different copy of it, and a bad node comes back as a 422 naming its path rather than as a schema error here.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Request body**

- `name` (`string`, required, 1 to 100 characters): Display name, 1 to 100 characters after trimming and unique per workspace.
- `slug` (`string`, up to 64 characters, pattern `^[a-z0-9][a-z0-9-]*$`): Stable handle of lowercase letters, digits and hyphens, at most 64 characters. Derived from `name` when omitted.
- `description` (`string`, nullable, up to 500 characters): Free text note, at most 500 characters.
- `publish` (`boolean`, default `false`): Compiles and publishes version 1 immediately. Defaults to false, which leaves a draft.
- `starter` (`string`, up to 64 characters): A starter slug from `listStarters`, which seeds the subject and the body. Anything you send yourself wins over the starter, and an unknown slug is a 404.
- `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` or `html`. Inferred from whether `html` is present.
- `subject` (`string`, up to 998 characters): Subject line with optional `{{placeholders}}`, at most 998 characters and free of line breaks.
- `document` (`object`): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
  - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
  - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
  - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
  - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
  - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
  - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
  - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
- `html` (`string`, up to 1000000 characters): Pre-rendered markup for the `html` engine. Required for it, at most 1,000,000 characters.
- `slots` (`TemplateSlot[]`): Replaces the whole slot list. Sending a shorter list DELETES the slots you left out, so read them first and send them all back. Omit the field entirely to leave them alone.
- `props` (`TemplateProp[]`): Replaces the whole prop list, with the same rule: what you leave out is gone, and a send that was passing it starts failing. Omit the field to leave them alone.

**Returns**

- `201` `Template`: Created, with version 1.

**Errors**

- `409`: `template_name_taken` or `template_slug_taken`. Both are per workspace.
- `422`: `invalid_template` (with `param` naming the offending path, down to the block) or `workspace_limit_reached` when the workspace is at its template limit.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.create()`](https://openemail.uk/docs/sdk/reference/templates#create); CLI [`openemail templates create`](https://openemail.uk/docs/cli/reference/templates#templates-create); MCP [`createTemplate`](https://openemail.uk/docs/mcp/tools/templates#createTemplate).

### `GET /templates/fonts`

List the web fonts a template can load

A template loads a web font by naming a family and the url it is served from, and 17 families are accepted: DM Sans, Inter, Roboto, Open Sans, Lato, Montserrat, Poppins, Nunito, Work Sans, Source Sans 3, IBM Plex Sans, Merriweather, Playfair Display, Instrument Serif, JetBrains Mono, DM Mono, Geist Mono. This is that list, with the exact url each one takes.

The list is closed on purpose. A font file is fetched by the reader's mail client the moment the message is opened, so a url pointing anywhere else would report opens to whoever runs that host, silently and regardless of what the workspace has open tracking set to. A `webFont.url` that is not the one this endpoint gives for its family is a 422 `invalid_template` naming the font.

One template may load 8 of them. A font whose family is not here still renders: leave `webFont` out and the family falls back to `fallbackFontFamily`, which is what Gmail and Outlook on Windows do with every web font anyway.

Static, not workspace data, so the same answer comes back for every key.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Returns**

- `200` `TemplateFontList`: The 17 fonts, in the order the console offers them.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.listFonts()`](https://openemail.uk/docs/sdk/reference/templates#listFonts); CLI [`openemail templates list-fonts`](https://openemail.uk/docs/cli/reference/templates#templates-list-fonts).

### `GET /templates/starters`

List the starter designs

The same catalogue the console offers when somebody starts a template, so an integration and a person begin from the same designs rather than from two divergent lists.

Not paginated and not workspace data: these ship with the product. The `slug` is what `POST /templates` takes as `starter` and what `POST /templates/{id}/content` takes to re-skin an existing template. Bodies and previews are on the single retrieval, because twenty rendered emails is not a list payload.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Returns**

- `200` `TemplateStarterList`: Every starter, bodies left out.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.listStarters()`](https://openemail.uk/docs/sdk/reference/templates#listStarters); CLI [`openemail templates list-starters`](https://openemail.uk/docs/cli/reference/templates#templates-list-starters); MCP [`getTemplateStarter`](https://openemail.uk/docs/mcp/tools/templates#getTemplateStarter), [`listTemplateStarters`](https://openemail.uk/docs/mcp/tools/templates#listTemplateStarters).

### `GET /templates/starters/{slug}`

Retrieve one starter

Adds the two heavy fields: `document`, the block tree, and `preview`, the starter rendered with its placeholders left visible.

The document is here so a client can edit a starter before creating anything from it. When you do not need to, `starter` on the create does the seeding server side in one call.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `slug` (`string`, required): A starter slug exactly as `GET /templates/starters` reports it. Starters are part of the product rather than workspace data, so the same slugs answer for every key.

**Returns**

- `200` `TemplateStarterDetail`: The starter with its block tree and a rendered preview.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.getStarter()`](https://openemail.uk/docs/sdk/reference/templates#getStarter); CLI [`openemail templates get-starter`](https://openemail.uk/docs/cli/reference/templates#templates-get-starter); MCP [`getTemplateStarter`](https://openemail.uk/docs/mcp/tools/templates#getTemplateStarter), [`listTemplateStarters`](https://openemail.uk/docs/mcp/tools/templates#listTemplateStarters).

### `POST /templates/render`

Render a body that is not stored

Compiles content you pass in and hands back what a message built from it would carry. No template is created, none is read, and nothing is sent. It is what the web editor calls while somebody types, and the way to check a design in CI before it becomes a template at all.

It takes exactly what a create takes as content, plus `values` for the placeholders. Lenient like a preview: an unfilled key comes back in `warnings` rather than refusing, because a body being rendered is usually a body being written. `mark: true` leaves every placeholder as `{{key}}`, which is how an editor shows an author what is a variable.

Needs only `templates:read`, since nothing is written, and it is the one templates path that names no template.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Request body**

- `engine` (`string`, one of `"blocks"`, `"html"`): Defaults to `html` when `html` is sent and `blocks` otherwise.
- `subject` (`string`, up to 998 characters): The subject to render, at most 998 characters.
- `document` (`object`): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
  - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
  - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
  - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
  - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
  - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
  - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
  - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
- `html` (`string`, up to 1000000 characters): The markup for the `html` engine, at most 1,000,000 characters.
- `slots` (`TemplateSlot[]`): Replaces the whole slot list. Sending a shorter list DELETES the slots you left out, so read them first and send them all back. Omit the field entirely to leave them alone.
- `props` (`TemplateProp[]`): Replaces the whole prop list, with the same rule: what you leave out is gone, and a send that was passing it starts failing. Omit the field to leave them alone.
- `mark` (`boolean`): Leaves placeholders as `{{key}}` rather than substituting them.
- `values` (`object`)
  - `props` (`Record<string, any>`)
  - `slots` (`Record<string, any>`)

**Returns**

- `200` `TemplateRender`: The rendered subject, HTML and text.

**Errors**

- `422`: `invalid_template`, with `param` naming the offending path.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.render()`](https://openemail.uk/docs/sdk/reference/templates#render); CLI [`openemail templates render`](https://openemail.uk/docs/cli/reference/templates#templates-render); MCP [`renderTemplate`](https://openemail.uk/docs/mcp/tools/templates#renderTemplate).

### `GET /templates/{id}`

Retrieve a template

`latest` is the head (the current draft, or the published version when nothing has been edited since), and it is the only place `document` and `html` are returned in full. The template's own `subject`, `engine`, `slots` and `props` still describe the PUBLISHED version, which is what a send would use, so the two disagree exactly while somebody is mid-edit.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Returns**

- `200` `object`: The template, with its head version in full.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `latest` (`TemplateVersion`)

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.get()`](https://openemail.uk/docs/sdk/reference/templates#get); CLI [`openemail templates get`](https://openemail.uk/docs/cli/reference/templates#templates-get); MCP [`getTemplate`](https://openemail.uk/docs/mcp/tools/templates#getTemplate).

### `PATCH /templates/{id}`

Update a template

Metadata (`name`, `description`, `status`) changes the template in place. Anything touching the body (`subject`, `document`, `html`, `slots`, `props`, `engine`) writes into the DRAFT, and mints version N+1 when the head is already published. Nothing here changes what a live send resolves to: that only moves when somebody publishes.

`expectedVersion` is the version you read before editing. Supply it and a concurrent edit is a 409 instead of a silent overwrite, which is what an automated writer wants; omit it and the last write wins, which is fine for one person in one tab.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Request body**

- `name` (`string`, 1 to 100 characters): Replacement name, 1 to 100 characters and unique per workspace. The slug does not follow it.
- `slug` (`string`, up to 64 characters, pattern `^[a-z0-9][a-z0-9-]*$`): Replacement slug of lowercase letters, digits and hyphens, at most 64 characters. Anything pinning the old slug starts getting 404s, and taking another template's slug is a 409 `template_slug_taken`.
- `description` (`string`, nullable, up to 500 characters): Replacement note of at most 500 characters, or null to clear it.
- `status` (`string`, one of `"active"`, `"archived"`): Archives or reactivates the template. It cannot be set back to `draft`.
- `expectedVersion` (`integer`, more than 0): The head version number you edited from. A mismatch is a 409 `version_conflict` and nothing is written.
- `engine` (`string`, one of `"blocks"`, `"html"`): Switches between `blocks` and `html`. Switching to `html` needs `html` supplied or already stored.
- `subject` (`string`, up to 998 characters): Replacement subject, at most 998 characters and free of line breaks.
- `document` (`object`): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
  - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
  - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
  - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
  - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
  - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
  - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
  - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
- `html` (`string`, up to 1000000 characters): Replacement markup for the `html` engine, at most 1,000,000 characters.
- `slots` (`TemplateSlot[]`): Replaces the whole slot list. Sending a shorter list DELETES the slots you left out, so read them first and send them all back. Omit the field entirely to leave them alone.
- `props` (`TemplateProp[]`): Replaces the whole prop list, with the same rule: what you leave out is gone, and a send that was passing it starts failing. Omit the field to leave them alone.

**Returns**

- `200` `object`: Saved. `latest` is the draft this edit wrote into.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `latest` (`TemplateVersion`)

**Errors**

- `409`: `version_conflict`, `template_name_taken` or `template_slug_taken`.
- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.update()`](https://openemail.uk/docs/sdk/reference/templates#update); CLI [`openemail templates update`](https://openemail.uk/docs/cli/reference/templates#templates-update); MCP [`updateTemplate`](https://openemail.uk/docs/mcp/tools/templates#updateTemplate).

### `DELETE /templates/{id}`

Delete a template

Takes every version with it. Mail already sent is untouched (a send stores what it rendered), but anything still posting to this id starts getting 404s, and there is no undo. `PATCH` with `status: "archived"` is the reversible version of this.

A tombstone rather than a 204, matching the rest of the API: the id comes back so a log line can name what went.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Returns**

- `200` `object`: Deleted.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`)
  - `deleted` (`boolean`, one of `true`)

**Errors**

- `409`: `template_in_use`: a broadcast that is scheduled or queued still names this template. Cancel it, or wait until it starts sending.
- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.delete()`](https://openemail.uk/docs/sdk/reference/templates#delete); CLI [`openemail templates delete`](https://openemail.uk/docs/cli/reference/templates#templates-delete); MCP [`deleteTemplate`](https://openemail.uk/docs/mcp/tools/templates#deleteTemplate).

### `POST /templates/{id}/duplicate`

Copy a template

Creates a separate template from the HEAD of this one: same subject, body, slots, props and description, at version 1 and always a DRAFT, whatever the source had published. A copy is therefore never sendable by accident.

Nothing links the copy to the source afterwards, which is what makes this the safe way to try a redesign of a template that is sending in production.

`name` is optional. Left out, the source name is reused, and since a name is unique per workspace the server appends a number until one is free, up to twenty. A name you send that is taken is treated the same way, so a nightly copy keeps working rather than failing on the second run.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Request body**

- `name` (`string`, 1 to 100 characters): The name for the copy, 1 to 100 characters. Omit it to reuse the source name with a number appended.

**Returns**

- `201` `object`: The new template, at version 1 as a draft.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `latest` (`TemplateVersion`)

**Errors**

- `409`: `template_name_taken`: every name from the base to the base plus 20 is in use.
- `422`: `workspace_limit_reached`: the workspace is at its template limit, and nothing was copied.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.duplicate()`](https://openemail.uk/docs/sdk/reference/templates#duplicate); CLI [`openemail templates duplicate`](https://openemail.uk/docs/cli/reference/templates#templates-duplicate); MCP [`duplicateTemplate`](https://openemail.uk/docs/mcp/tools/templates#duplicateTemplate).

### `POST /templates/{id}/content`

Replace the design

Swaps the whole body for a starter design or for another template's, keeping this template's identity: the id, slug, name, description and status do not move, and neither does what a live send resolves to.

The write lands where a body edit lands. A draft head is overwritten in place; a published head mints version N+1 as a draft. Sends keep using the published version until somebody publishes, which is what makes this safe to call on a template that is sending.

Name exactly one source. `starter` takes a slug from `GET /templates/starters`; `fromTemplateId` takes another template in this workspace, whose published version is copied when it has one and whose draft is copied otherwise. A source in another workspace is a 404 like anything else, and the template naming itself is a 422.

The new body brings the source's slots and props with it and replaces the declarations wholesale, so a send that passed the old props can start being refused. Read the response before publishing.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Request body**

- `starter` (`string`, up to 64 characters): A starter slug from `listStarters`. Mutually exclusive with `fromTemplateId`.
- `fromTemplateId` (`string`, 1 to 128 characters): Another template in this workspace to borrow the design from, by id or slug. Mutually exclusive with `starter`.
- `expectedVersion` (`integer`, more than 0): The head version you read before replacing. A mismatch is a 409 `version_conflict` and nothing is written.

**Returns**

- `200` `object`: Replaced. `latest` is the version this call wrote into.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `latest` (`TemplateVersion`)

**Errors**

- `409`: `version_conflict`: another writer moved the head since your read.
- `422`: `invalid_template`: no source was named, both were, or the target was named as its own source.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.replaceContent()`](https://openemail.uk/docs/sdk/reference/templates#replaceContent); CLI [`openemail templates replace-content`](https://openemail.uk/docs/cli/reference/templates#templates-replace-content); MCP [`replaceTemplateContent`](https://openemail.uk/docs/mcp/tools/templates#replaceTemplateContent).

### `GET /templates/{id}/versions`

List versions

Bodies are omitted here. `document` and `html` come back on a retrieval or a publish, because a list of twenty block trees is a payload nobody asked for. What each version DECLARES (`slots`, `props`, `subject`) is present, which is what makes this the endpoint for "what would pinning version 3 commit me to".

To read one old body, GET the version itself at `/templates/{id}/versions/{version}`. That is the call for a diff against today's draft, and it does not disturb the head the way a restore does.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Query parameters**

- `limit` (`integer`, at least 1, at most 100, default `25`): Rows per page, 1 to 100.
- `cursor` (`string`): The previous page's `nextCursor`, passed back as it came. It is opaque: it holds where the last row sat in this list's order, so a row deleted or edited between pages never breaks the walk, and the next page starts at the first row that sorts after it. A value this list did not hand out is a 400 `invalid_cursor`.

**Returns**

- `200` `TemplateVersionList`: A page of versions, newest first. Every publish adds one, so follow `nextCursor` while `hasMore` is true to reach version 1. Keyset on the version number, so a version deleted between pages never breaks the walk.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.listVersions()`](https://openemail.uk/docs/sdk/reference/templates#listVersions), [`templates.listAllVersions()`](https://openemail.uk/docs/sdk/reference/templates#listAllVersions), [`templates.iterateVersions()`](https://openemail.uk/docs/sdk/reference/templates#iterateVersions); CLI [`openemail templates list-versions`](https://openemail.uk/docs/cli/reference/templates#templates-list-versions); MCP [`getTemplateVersion`](https://openemail.uk/docs/mcp/tools/templates#getTemplateVersion), [`listTemplateVersions`](https://openemail.uk/docs/mcp/tools/templates#listTemplateVersions).

### `POST /templates/{id}/versions`

Publish the draft

A POST to the version COLLECTION, because that is what it creates: a frozen revision that live sends resolve against. Compiling happens here, so a template that does not render fails for the person publishing it rather than for the recipient of the next message.

Idempotent: publishing an already-published head returns it unchanged, so a deploy script that publishes unconditionally is safe to run twice.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Returns**

- `201` `object`: Published. Live sends resolve to this from now on.
  - `object` (`string`, one of `"template_version"`)
  - `id` (`string`): `tplv_` + 24 hex. Not the template id.
  - `templateId` (`string`)
  - `version` (`integer`): Counts from 1, per template. This is what a send pins.
  - `state` (`string`, one of `"draft"`, `"published"`): There is exactly one draft at a time and it is always the highest version. Editing writes into it in place; publishing freezes it and the next edit mints the one after.
  - `engine` (`string`, one of `"blocks"`, `"html"`)
  - `subject` (`string`)
  - `slots` (`TemplateSlot[]`)
  - `props` (`TemplateProp[]`)
  - `document` (`object`, nullable): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
    - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
    - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
    - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
    - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
    - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
    - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
    - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
  - `html` (`string`, nullable): The markup exactly as it was submitted, for the `html` engine, and not what goes out: sanitising happens at publish, against the compiled copy. Returned where `document` is.
  - `publishedAt` (`string`, nullable, format `date-time`)
  - `createdAt` (`string`, format `date-time`)
  - `template` (`Template`)

**Errors**

- `409`: `version_conflict`: another writer published between your read and this call.
- `422`: `invalid_template`. The draft does not compile; nothing was published.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.publish()`](https://openemail.uk/docs/sdk/reference/templates#publish); CLI [`openemail templates publish`](https://openemail.uk/docs/cli/reference/templates#templates-publish); MCP [`publishTemplate`](https://openemail.uk/docs/mcp/tools/templates#publishTemplate).

### `GET /templates/{id}/versions/{version}`

Retrieve one version

One frozen revision with its body: `document` for the `blocks` engine, `html` for the `html` engine, plus the `subject`, `slots` and `props` that were declared at the time.

This is what the version LIST leaves out, and it is the only way to see what an old version holds without changing anything. Restoring used to be the only route to that, and a restore moves the head, so reading version 3 cost you your draft. It does not any more.

Use it to diff a regression against the revision that worked, to copy a block out of an older design, or to archive what a campaign actually said. `POST /templates/{id}/versions/{version}/restore` is still the call that brings it back.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.
- `version` (`integer`, required, at least 1): A version NUMBER as `GET /templates/{id}/versions` reports it, counting from 1, not a `tplv_` id. Numbers are never reused, so a deleted one stays gone.

**Returns**

- `200` `TemplateVersion`: That revision, body included.

**Errors**

- `404`: `template_version_not_found`: no version with that number, or it was deleted.
- The errors every operation can return: `400`, `401`, `403`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.getVersion()`](https://openemail.uk/docs/sdk/reference/templates#getVersion); CLI [`openemail templates get-version`](https://openemail.uk/docs/cli/reference/templates#templates-get-version); MCP [`getTemplateVersion`](https://openemail.uk/docs/mcp/tools/templates#getTemplateVersion), [`listTemplateVersions`](https://openemail.uk/docs/mcp/tools/templates#listTemplateVersions).

### `DELETE /templates/{id}/versions/{version}`

Delete one version

Removes one revision and leaves the template alone. It is for tidying a long version list, not for changing what sends.

Three versions are refused, each as a 422 rather than a quiet success: the LIVE one, because sends resolve it; the HEAD, because that is the draft being edited, and restoring an older version first is how you move off it; and the only version a template has, because a template with no versions could not be read, so delete the template instead.

Mail already sent is untouched, since a send stores what it rendered. A send that pins a deleted `version` starts failing with `template_version_not_found`, and the number is never reused.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.
- `version` (`integer`, required, at least 1): A version NUMBER as `GET /templates/{id}/versions` reports it, counting from 1, not a `tplv_` id. Numbers are never reused, so a deleted one stays gone.

**Returns**

- `200` `object`: Deleted.
  - `object` (`string`, one of `"template_version"`)
  - `templateId` (`string`)
  - `version` (`integer`)
  - `deleted` (`boolean`, one of `true`)

**Errors**

- `422`: `template_version_not_deletable`: the version is the live one, the head being edited, or the only version there is.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.deleteVersion()`](https://openemail.uk/docs/sdk/reference/templates#deleteVersion); CLI [`openemail templates delete-version`](https://openemail.uk/docs/cli/reference/templates#templates-delete-version); MCP [`deleteTemplateVersion`](https://openemail.uk/docs/mcp/tools/templates#deleteTemplateVersion).

### `POST /templates/{id}/versions/{version}/restore`

Restore an older version

Copies an older version's subject, body, slots and props forward into the head, which is how a design you regret is undone. Nothing is rolled back in place: the old version stays in the list and the restored copy becomes the current draft.

A published head mints version N+1; a draft head is overwritten, so restoring twice does not pile up versions. It does not publish, so live sends do not move until you do, and the restore is itself reversible until then.

Restoring the head is a 422 rather than a no-op, because it would silently do nothing. `expectedVersion` guards against a concurrent editor exactly as it does on a PATCH.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.
- `version` (`integer`, required, at least 1): A version NUMBER as `GET /templates/{id}/versions` reports it, counting from 1, not a `tplv_` id. Numbers are never reused, so a deleted one stays gone.

**Request body**

- `expectedVersion` (`integer`, more than 0): The head version you read before restoring. A mismatch is a 409 `version_conflict` and nothing is written.

**Returns**

- `200` `object`: Restored into the head. Nothing live has moved.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `latest` (`TemplateVersion`)
  - `restoredFrom` (`integer`): The version the body was copied from.

**Errors**

- `409`: `version_conflict`: another writer moved the head since your read.
- `422`: `invalid_template`: that version is the head, so there is nothing to restore.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.restoreVersion()`](https://openemail.uk/docs/sdk/reference/templates#restoreVersion); CLI [`openemail templates restore-version`](https://openemail.uk/docs/cli/reference/templates#templates-restore-version).

### `POST /templates/{id}/preview`

Render without sending

The endpoint to point CI at. It resolves a version, substitutes the values and hands back exactly what a send would put in the message, so a template change is caught by a test rather than by a customer.

Lenient where a send is strict: a missing required prop comes back in `warnings` rather than refusing, because a template being previewed is usually a template being written. A send gets no such leniency. `version` may name a DRAFT here, which is the other thing a send cannot do.

Always `application/json`, never `text/html`. The HTML is a string field. A raw document would be `JSON.parse`d by every SDK transport and resolve to nothing at all.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Request body**

- `version` (`integer`, more than 0): Version to render, drafts included. Defaults to the published version.
- `props` (`Record<string, any>`): Values for declared props, keyed by prop key. Strings, numbers and booleans only.
- `slots` (`Record<string, any>`): Overrides for slot defaults, keyed by slot key.

**Returns**

- `200` `TemplatePreview`: The rendered subject, HTML and text.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.preview()`](https://openemail.uk/docs/sdk/reference/templates#preview); CLI [`openemail templates preview`](https://openemail.uk/docs/cli/reference/templates#templates-preview); MCP [`previewTemplate`](https://openemail.uk/docs/mcp/tools/templates#previewTemplate).

### `GET /templates/{id}/analytics`

How a template has performed

The report the console shows on a template: how much it sent, how much of that was tracked, how much was opened and clicked, and the same figures by day, by source and by version.

Rates are computed over what was TRACKED rather than over everything sent, because a message sent with tracking off can never report an open and counting it would quietly lower every rate. Both denominators are in the response so nothing has to be taken on trust.

`lifetime` ignores the window: every live send ever, the test-key sends counted separately, and the first and last time it sent. A template that has never sent answers with zeroes rather than a 404; the 404 is for a template that does not exist.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Query parameters**

- `days` (`integer`, at least 1, at most 365): How far back to look. Defaults to 30. The window starts at the beginning of that day in the offset you asked for and ends now, so the newest bucket is partial.
- `minutes` (`integer`, at least 1, at most 1576800): The window in minutes, which wins over `days`. A whole number of days cannot say "the last hour", which is the report worth having while a send is going out.
- `grain` (`string`, one of `"minute"`, `"hour"`, `"day"`): How wide one `byDay` bucket is, and the shape of its key: `YYYY-MM-DD`, `YYYY-MM-DDTHH` or `YYYY-MM-DDTHH:MM`.
- `offsetMinutes` (`integer`, at least -840, at most 840): The reader's UTC offset in minutes, so a day is their day rather than UTC's.

**Returns**

- `200` `TemplateAnalytics`: The engagement report for this template.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.getAnalytics()`](https://openemail.uk/docs/sdk/reference/templates#getAnalytics); CLI [`openemail templates get-analytics`](https://openemail.uk/docs/cli/reference/templates#templates-get-analytics); MCP [`getTemplateAnalytics`](https://openemail.uk/docs/mcp/tools/templates#getTemplateAnalytics).

### `GET /templates/{id}/sends`

The messages a template sent

One row per message this template rendered, newest first, with the subject as it went out, who it went to, and whether it was opened or clicked. It is the list behind the analytics numbers, and the way to answer "did this person get it".

`total` counts the rows matching the filters rather than the page, which is what a table needs. Page numbers are not stable while mail is going out, since a new send pushes rows down: narrow the window rather than paging deep.

`opens` and `clicks` say what the message ASKED for; `openCount` and `clickCount` say what happened. Only live sends are listed.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Query parameters**

- `days` (`integer`, at least 1, at most 365): How far back to look. Defaults to 30.
- `minutes` (`integer`, at least 1, at most 1576800): The window in minutes, which wins over `days`.
- `grain` (`string`, one of `"minute"`, `"hour"`, `"day"`): Only floors the start of the window, so this list can cover the same window as the analytics read at that grain. It shapes nothing in the response.
- `offsetMinutes` (`integer`, at least -840, at most 840): The reader's UTC offset in minutes.
- `page` (`integer`, at least 1): Which page, from 1. Defaults to 1. Page numbers, not a cursor, because the console shows a table with a total.
- `pageSize` (`integer`, at least 1, at most 100): Rows per page, 1 to 100. Defaults to 25.
- `search` (`string`, up to 200 characters): Matches the subject that went out and the recipient addresses.
- `source` (`string`, up to 64 characters): Only sends from one surface, such as the API or the console.
- `version` (`integer`, more than 0): Only sends that rendered this version number.
- `opened` (`string`, one of `"true"`, `"false"`): True for sends with at least one counted open, false for none of them.
- `clicked` (`string`, one of `"true"`, `"false"`): True for sends with at least one counted click, false for none of them.
- `tracked` (`string`, one of `"true"`, `"false"`): True for sends that carried tracking at all. The false side is the mail that could never have reported anything.

**Returns**

- `200` `TemplateSendList`: One row per message, newest first, with the total matching the filters.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.listSends()`](https://openemail.uk/docs/sdk/reference/templates#listSends); CLI [`openemail templates list-sends`](https://openemail.uk/docs/cli/reference/templates#templates-list-sends); MCP [`listTemplateSends`](https://openemail.uk/docs/mcp/tools/templates#listTemplateSends).

### `POST /templates/{id}/send`

Send with a template

Resolves the published version (or the one `version` pins), substitutes `props` and `slots`, and sends. `subject` overrides the version's own for this message only.

Props are validated STRICTLY, which is the opposite of the preview: an unknown key is refused rather than ignored, and a declared `required` key that is missing is refused rather than rendered as a blank. Mail cannot be recalled, and a hole where the order number should be is not something a recipient can report back to you.

Also requires `emails:send`, and neither scope is redundant: reaching a stored body is `templates:write`, mail leaving the workspace is `emails:send`. A key that may send its own bodies still cannot send somebody else's template.

`POST /emails` takes a `template` field that does the same thing, for a caller already composing a message.

Requires the `templates:write` and `emails:send` scopes.

- Scopes: `templates:write`, `emails:send`.
- Honours `Idempotency-Key`.

**Path parameters**

- `id` (`string`, required): A `tpl_` id or the template's slug. Both are accepted on every templates path: an integration pins a slug and a UI passes an id, and making either one say which it holds buys nothing. Ids carry the prefix, so the two vocabularies cannot collide.

**Headers**

- `Idempotency-Key` (`string`, up to 255 characters, pattern `^[A-Za-z0-9_.:-]+$`): Makes a retry safe. Reusing one with a different body is a 422.

**Request body**

- `from` (`string | object`, required): Sender as `address`, `Name <address>` or `{ email, name }`. The key must be allowed to send as it, otherwise 403 `from_address_forbidden`.
  - `email` (`string`, required, up to 320 characters)
  - `name` (`string`, up to 128 characters)
- `to` (`(string | object)[]`, required, 1 to 50 items): 1 to 50 recipients. The SDK wraps a single value in an array.
  - `email` (`string`, required, up to 320 characters)
  - `name` (`string`, up to 128 characters)
- `cc` (`(string | object)[]`, up to 50 items, default `[]`): Up to 50 copied recipients.
  - `email` (`string`, required, up to 320 characters)
  - `name` (`string`, up to 128 characters)
- `bcc` (`(string | object)[]`, up to 50 items, default `[]`): Up to 50 blind copied recipients.
  - `email` (`string`, required, up to 320 characters)
  - `name` (`string`, up to 128 characters)
- `replyTo` (`string | object`): Sets the Reply-To header.
  - `email` (`string`, required, up to 320 characters)
  - `name` (`string`, up to 128 characters)
- `version` (`integer`, more than 0): Published version to send. Defaults to the currently published one.
- `props` (`Record<string, any>`): Values for the declared props: strings, numbers or booleans, keyed by prop key.
- `slots` (`Record<string, any>`): Overrides for slot defaults, keyed by slot key.
- `subject` (`string`, up to 998 characters): Replaces the version's subject for this message, sent verbatim, at most 998 characters.
- `scheduledAt` (`string`, 3 to 64 characters): When to send: a `Date`, an ISO 8601 instant or a duration like `PT2H`. Must be in the future and at most 365 days out.
- `cancellableForSeconds` (`integer`, at least 0, at most 900, default `0`): Holds the message 0 to 900 seconds so it can still be cancelled. Defaults to 0 and is refused alongside `scheduledAt`.
- `tracking` (`object`): Per message `opens` and `clicks` switches for open and click tracking.
  - `opens` (`boolean`)
  - `clicks` (`boolean`)
- `translate` (`object`): Translates the rendered message into `to` before sending, with optional `from`, `includeOriginal` (default true) and `subject` (default true).
  - `to` (`string`, required, 2 to 60 characters)
  - `from` (`string`, 2 to 60 characters)
  - `includeOriginal` (`boolean`, default `true`)
  - `subject` (`boolean`, default `true`)
- `tags` (`Record<string, string>`, default `{}`): Your own labels for the send, keys up to 64 and values up to 256 characters.

**Returns**

- `200` `object`: An `Idempotency-Key` replayed an earlier send. This is that message, not a new one.
  - `object` (`string`, one of `"email"`)
  - `id` (`string`): The durable handle, `msg_` + 24 hex.
  - `status` (`string`, one of `"queued"`, `"scheduled"`, `"sending"`, `"sent"`, `"partial"`, `"cancelled"`, `"failed"`)
  - `mode` (`string`, one of `"live"`, `"test"`)
  - `from` (`string`)
  - `subject` (`string`, nullable)
  - `messageId` (`string`, nullable): RFC 5322 Message-ID. Null until the MIME exists. Do NOT correlate on it: the header is rewritten on the way out, so the value here appears in no bounce or delivery report and a match on it never fires. A delivery event names the send by its `id`, as `emailId`.
  - `threadId` (`string`, nullable)
  - `transport` (`string`, nullable, one of `"ses"`, `"test"`, `"dev"`): How the bytes left, once they have. Null until dispatch. `test` is what a message sent with an `oe_test_` key records: it was accepted and every recipient marked delivered, but nothing was carried. `dev` is not a way of sending either: it is what a message records where nothing is configured to carry mail, having been built and sent nowhere.
  - `attempts` (`integer`)
  - `lastError` (`string`, nullable)
  - `scheduledAt` (`string`, nullable, format `date-time`)
  - `cancellableUntil` (`string`, nullable, format `date-time`)
  - `sentAt` (`string`, nullable, format `date-time`)
  - `tags` (`Record<string, string>`)
  - `broadcastId` (`string`, nullable): The `brd_` broadcast this message is one copy of, or null for a message sent on its own. `GET /emails?broadcastId=` lists every copy of one broadcast.
  - `source` (`string`, one of `"api"`, `"oauth"`, `"composer"`, `"mcp"`, `"ai"`, `"form"`)
  - `createdAt` (`string`, format `date-time`)
  - `recipients` (`object[]`): Returned on retrieval only.
    - `email` (`string`)
    - `name` (`string`, nullable)
    - `kind` (`string`, one of `"to"`, `"cc"`, `"bcc"`)
    - `status` (`string`, one of `"pending"`, `"delivered"`, `"failed"`, `"bounced"`, `"complained"`, `"suppressed"`, `"uncertain"`): `uncertain` is real and is shown as itself: a transport that failed part-way cannot say which recipients it reached, and calling those delivered or failed would both be guesses. `suppressed` is decided before dispatch rather than reported afterwards: the address bounced or complained in this workspace before, so this copy was never offered to the transport. A message whose recipients are all suppressed fails outright.
    - `error` (`string`, nullable)
    - `deliveredAt` (`string`, nullable, format `date-time`)
  - `translation` (`object`): Present only when the message was translated, and only on responses that carry the stored request, which are the send itself and a retrieval. A list row does not fetch it, so its absence there says nothing either way.
    - `language` (`string`): The resolved target code: `de`, `pt-BR`.
    - `languageName` (`string`): Its English name.
    - `detectedSourceLanguage` (`string`, nullable): Stated or detected. Null when detection abstained.
    - `subject` (`boolean`): Whether the subject was translated too.
    - `includeOriginal` (`boolean`): Whether the sender's own words went below the translation.
  - `tracking` (`Tracking`)
  - `template` (`object`)
    - `id` (`string`)
    - `version` (`integer`, nullable)
- `202` `object`: Accepted. `template.version` is the version that went out, whether you pinned it or the published one was resolved.
  - `object` (`string`, one of `"email"`)
  - `id` (`string`): The durable handle, `msg_` + 24 hex.
  - `status` (`string`, one of `"queued"`, `"scheduled"`, `"sending"`, `"sent"`, `"partial"`, `"cancelled"`, `"failed"`)
  - `mode` (`string`, one of `"live"`, `"test"`)
  - `from` (`string`)
  - `subject` (`string`, nullable)
  - `messageId` (`string`, nullable): RFC 5322 Message-ID. Null until the MIME exists. Do NOT correlate on it: the header is rewritten on the way out, so the value here appears in no bounce or delivery report and a match on it never fires. A delivery event names the send by its `id`, as `emailId`.
  - `threadId` (`string`, nullable)
  - `transport` (`string`, nullable, one of `"ses"`, `"test"`, `"dev"`): How the bytes left, once they have. Null until dispatch. `test` is what a message sent with an `oe_test_` key records: it was accepted and every recipient marked delivered, but nothing was carried. `dev` is not a way of sending either: it is what a message records where nothing is configured to carry mail, having been built and sent nowhere.
  - `attempts` (`integer`)
  - `lastError` (`string`, nullable)
  - `scheduledAt` (`string`, nullable, format `date-time`)
  - `cancellableUntil` (`string`, nullable, format `date-time`)
  - `sentAt` (`string`, nullable, format `date-time`)
  - `tags` (`Record<string, string>`)
  - `broadcastId` (`string`, nullable): The `brd_` broadcast this message is one copy of, or null for a message sent on its own. `GET /emails?broadcastId=` lists every copy of one broadcast.
  - `source` (`string`, one of `"api"`, `"oauth"`, `"composer"`, `"mcp"`, `"ai"`, `"form"`)
  - `createdAt` (`string`, format `date-time`)
  - `recipients` (`object[]`): Returned on retrieval only.
    - `email` (`string`)
    - `name` (`string`, nullable)
    - `kind` (`string`, one of `"to"`, `"cc"`, `"bcc"`)
    - `status` (`string`, one of `"pending"`, `"delivered"`, `"failed"`, `"bounced"`, `"complained"`, `"suppressed"`, `"uncertain"`): `uncertain` is real and is shown as itself: a transport that failed part-way cannot say which recipients it reached, and calling those delivered or failed would both be guesses. `suppressed` is decided before dispatch rather than reported afterwards: the address bounced or complained in this workspace before, so this copy was never offered to the transport. A message whose recipients are all suppressed fails outright.
    - `error` (`string`, nullable)
    - `deliveredAt` (`string`, nullable, format `date-time`)
  - `translation` (`object`): Present only when the message was translated, and only on responses that carry the stored request, which are the send itself and a retrieval. A list row does not fetch it, so its absence there says nothing either way.
    - `language` (`string`): The resolved target code: `de`, `pt-BR`.
    - `languageName` (`string`): Its English name.
    - `detectedSourceLanguage` (`string`, nullable): Stated or detected. Null when detection abstained.
    - `subject` (`boolean`): Whether the subject was translated too.
    - `includeOriginal` (`boolean`): Whether the sender's own words went below the translation.
  - `tracking` (`Tracking`)
  - `template` (`object`)
    - `id` (`string`)
    - `version` (`integer`, nullable)

**Errors**

- `409`: `domain_not_sendable`: the `from` domain cannot sign mail yet, so nothing was accepted.
- `422`: The props did not satisfy the version: `missing_template_prop`, `unknown_template_prop`, `unknown_template_slot` or `invalid_template_prop`, each with `param` naming the key. Also `template_not_published` when nothing has been published yet.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.send()`](https://openemail.uk/docs/sdk/reference/templates#send); CLI [`openemail templates send`](https://openemail.uk/docs/cli/reference/templates#templates-send).

### `GET /templates/images`

List template images

The images uploaded for templates, newest first: the same library the template editor offers. Template images belong to the workspace: every template in it can use them, and they stay at their address after the template that first used them is deleted.

Requires the `templates:read` scope.

- Scopes: `templates:read`.

**Query parameters**

- `limit` (`integer`, at least 1, at most 120, default `24`): Rows per page, 1 to 120.
- `cursor` (`string`): The previous page's `nextCursor`, passed back as it came. It is opaque: it holds where the last row sat in this list's order, so a row deleted or edited between pages never breaks the walk, and the next page starts at the first row that sorts after it. A value this list did not hand out is a 400 `invalid_cursor`.

**Returns**

- `200` `TemplateImageList`: A page of images.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.listImages()`](https://openemail.uk/docs/sdk/reference/templates#listImages), [`templates.listAllImages()`](https://openemail.uk/docs/sdk/reference/templates#listAllImages), [`templates.iterateImages()`](https://openemail.uk/docs/sdk/reference/templates#iterateImages); CLI [`openemail templates list-images`](https://openemail.uk/docs/cli/reference/templates#templates-list-images); MCP [`listTemplateImages`](https://openemail.uk/docs/mcp/tools/templates#listTemplateImages).

### `POST /templates/images`

Upload a template image

Send the image bytes as the request body with their type as `Content-Type`: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/svg+xml`, up to 5 MB. It is fitted into 1200 by 1800 pixels and stored in a form every mail client shows, and `url` is the public address to put in a template. Template images belong to the workspace: every template in it can use them, and they stay at their address after the template that first used them is deleted.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Request body**

Content type: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/svg+xml`.

`binary`

**Returns**

- `201` `UploadedTemplateImage`: Stored. Use `url` in a template.

**Errors**

- `422`: `invalid_image`: the body is not an image of an accepted type, is over 5 MB, or cannot be read.
- `502`: `image_not_stored`: the image was read but could not be stored. Try again.
- `503`: `image_busy`: the image service is busy. Try again.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.uploadImage()`](https://openemail.uk/docs/sdk/reference/templates#uploadImage); CLI [`openemail templates upload-image`](https://openemail.uk/docs/cli/reference/templates#templates-upload-image); MCP [`uploadTemplateImage`](https://openemail.uk/docs/mcp/tools/templates#uploadTemplateImage).

### `POST /templates/design`

Design a template from a brief

A designer builds a new block template from a written brief, the way the assistant in the app does, and saves it as a draft unless `publish` is true. `starter` builds on a starter design, and `imageFileIds` names up to ten uploaded or received images to place in it. It spends one AI action and can take up to a minute.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Request body**

- `name` (`string`, required, 1 to 100 characters): A short name for the template. When the name is taken, a free one is chosen and returned.
- `brief` (`string`, required, 1 to 8000 characters): Everything the design must hold and look like, up to 8,000 characters.
- `description` (`string`, up to 500 characters): One line about what it is for.
- `starter` (`string`, 1 to 64 characters): The slug of a starter design to build on, from `listStarters`.
- `imageFileIds` (`string[]`, up to 10 items): Up to ten file ids of images to place in the design.
- `publish` (`boolean`): Publish it at once so it can be sent. Defaults to false.

**Returns**

- `201` `object`: The new template.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `design` (`object`)
    - `subject` (`string`): The subject the designer wrote or kept.
    - `notes` (`string[]`): What was adjusted while saving the design.
    - `imageProblems` (`string[]`): Each image in `imageFileIds` that could not be used, and why.

**Errors**

- `409`: `ai_not_configured`: this server has no model to design with.
- `422`: `invalid_template` when the design could not be made valid, or the template is raw HTML. `invalid_parameter` for a body that does not validate.
- `429`: `ai_quota_exceeded`: the workspace has used its AI actions for today.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.design()`](https://openemail.uk/docs/sdk/reference/templates#design); CLI [`openemail templates design`](https://openemail.uk/docs/cli/reference/templates#templates-design).

### `POST /templates/{id}/redesign`

Change a template’s design from instructions

A designer applies written instructions to the block template and leaves everything else alone: restyle it, rewrite or translate its copy, add, move or remove sections. The change lands in the draft, and live sends keep the published version until it is published. It spends one AI action.

Requires the `templates:write` scope.

- Scopes: `templates:write`.

**Path parameters**

- `id` (`string`, required)

**Request body**

- `instructions` (`string`, required, 1 to 8000 characters): What to change, with every detail: the words, colours and which section. Up to 8,000 characters.
- `imageFileIds` (`string[]`, up to 10 items): Up to ten file ids of images to use.
- `expectedVersion` (`integer`, more than 0): The version you based the change on. A newer one is refused with 409 `version_conflict`.

**Returns**

- `200` `object`: The template, with `design.changes` and the draft version that holds them.
  - `object` (`string`, one of `"template"`)
  - `id` (`string`): The durable handle, `tpl_` + 24 hex.
  - `name` (`string`)
  - `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
  - `description` (`string`, nullable)
  - `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
  - `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
  - `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
  - `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
  - `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
  - `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
  - `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
  - `createdAt` (`string`, format `date-time`)
  - `updatedAt` (`string`, format `date-time`)
  - `design` (`object`)
    - `subject` (`string`): The subject the designer wrote or kept.
    - `notes` (`string[]`): What was adjusted while saving the design.
    - `imageProblems` (`string[]`): Each image in `imageFileIds` that could not be used, and why.

**Errors**

- `409`: `ai_not_configured`: this server has no model to design with.
- `422`: `invalid_template` when the design could not be made valid, or the template is raw HTML. `invalid_parameter` for a body that does not validate.
- `429`: `ai_quota_exceeded`: the workspace has used its AI actions for today.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`templates.redesign()`](https://openemail.uk/docs/sdk/reference/templates#redesign); CLI [`openemail templates redesign`](https://openemail.uk/docs/cli/reference/templates#templates-redesign).

### Objects

#### `Template`

`object`

- `object` (`string`, one of `"template"`)
- `id` (`string`): The durable handle, `tpl_` + 24 hex.
- `name` (`string`)
- `slug` (`string`): Derived from the name when the template is created and never re-derived on rename, so retitling "Order shipped" to "Dispatch confirmation" cannot 404 an integration that has been sending against it. This is the handle to pin in code; every path taking an `{id}` takes it too.
- `description` (`string`, nullable)
- `status` (`string`, one of `"draft"`, `"active"`, `"archived"`): Whether anybody is meant to reach for it, which is not the same question as whether it has a published version. `archived` is the reversible form of deleting one.
- `publishedVersion` (`integer`, nullable): The revision a live send resolves to. Null means nothing has been published and the template cannot be sent. A send is refused with `template_not_published` rather than quietly going out as whatever the draft currently says.
- `latestVersion` (`integer`): The head. Equal to `publishedVersion` until somebody edits the body, which mints the next draft and leaves live sends on the published one.
- `subject` (`string`): The published version's, and absent when nothing is published. Carries `{{placeholders}}` filled from the same values the body uses, so it is versioned with the body rather than stored on the template.
- `engine` (`string`, one of `"blocks"`, `"html"`): `blocks` is a validated tree over the exports of `@react-email/components`, checked at its input. `html` is markup the caller rendered themselves, sanitised once at publish. Absent when nothing is published, for the same reason `subject` is: it is a property of a version, not of the template.
- `slots` (`TemplateSlot[]`): The published version's declarations. Absent when nothing is published.
- `props` (`TemplateProp[]`): What a send must supply. Read the `required` ones off this rather than off a preview: a preview tolerates them being missing and a send does not.
- `createdAt` (`string`, format `date-time`)
- `updatedAt` (`string`, format `date-time`)

#### `TemplateAnalytics`

`object`

- `object` (`string`, one of `"template_analytics"`)
- `templateId` (`string`): Always the `tpl_` id, even when a slug was asked for.
- `sends` (`integer`): Messages this template rendered in the window, live keys only. A test-key send is counted in `lifetime.testSends` and nowhere else.
- `matched` (`integer`): How many of those renders were paired with a tracked message. The gap between this and `sends` is mail that carried no tracking at all, not data that went missing.
- `trackedForOpens` (`integer`): The denominator behind `openRate`. Counting untracked sends in it would quietly lower every rate.
- `trackedForClicks` (`integer`): The denominator behind `clickRate`.
- `opened` (`integer`): Sends with at least one counted open.
- `clicked` (`integer`): Sends with at least one counted click.
- `openRate` (`number`): A percentage to one decimal place, over `trackedForOpens`. Zero rather than null when nothing was tracked.
- `clickRate` (`number`): The same, over `trackedForClicks`.
- `totalOpens` (`integer`): Every counted open, repeats included.
- `totalClicks` (`integer`)
- `bySource` (`object[]`): Which surface sent it: the API, the console, a rule, the assistant.
  - `source` (`string`)
  - `sends` (`integer`)
- `byDay` (`object[]`): One entry per bucket at the grain you asked for. The key is `YYYY-MM-DD` for a day, `YYYY-MM-DDTHH` for an hour, `YYYY-MM-DDTHH:MM` for a minute.
  - `day` (`string`)
  - `sent` (`integer`)
  - `sends` (`integer`)
  - `trackedForOpens` (`integer`)
  - `trackedForClicks` (`integer`)
  - `opened` (`integer`)
  - `clicked` (`integer`)
- `byVersion` (`object[]`): The same figures per version, newest first, which is how a redesign is compared with what it replaced.
  - `version` (`integer`)
  - `sends` (`integer`)
  - `trackedForOpens` (`integer`)
  - `trackedForClicks` (`integer`)
  - `opened` (`integer`)
  - `clicked` (`integer`)
- `recent` (`object[]`): A preview: the twelve newest sends in the window, newest first, with their own counts. Every send in the window, a page at a time, is `GET /templates/{id}/sends`.
  - `id` (`string`)
  - `version` (`integer`)
  - `source` (`string`)
  - `createdAt` (`string`, format `date-time`)
  - `matched` (`boolean`)
  - `opens` (`boolean`): Whether the message asked to be tracked for opens.
  - `clicks` (`boolean`)
  - `openCount` (`integer`)
  - `clickCount` (`integer`)
- `lifetime` (`object`): Ignores the window entirely: every send this template has ever made.
  - `sends` (`integer`)
  - `testSends` (`integer`): Sent with a test key, so nothing was carried.
  - `firstAt` (`string`, nullable, format `date-time`)
  - `lastAt` (`string`, nullable, format `date-time`)

#### `TemplateFont`

`object`

One web font the template loads. A mail client that blocks the download falls back to `fallbackFontFamily`, so name a real stack there rather than a second web font.

- `fontFamily` (`string`, required, up to 128 characters): The family name as the blocks reference it, for example `Playfair Display`. Letters, digits, spaces, dots, hyphens, commas and quotes.
- `fallbackFontFamily` (`string`, required, up to 128 characters): What renders when the web font does not, for example `Georgia, serif`. Required: a font with no fallback is a message that renders in whatever the client picked.
- `webFont` (`object`): Where the file lives. Leave it out for a font already installed on the reader's machine, such as Georgia or Arial, which costs no download and is the safer choice in mail.
  - `url` (`string`, required, one of `"https://fonts.gstatic.com/s/dmsans/v17/rP2Yp2ywxg089UriI5-g4vlH9VoD8Cmcqbu0-K6z9mXg.woff2"`, `"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2"`, `"https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2"`, `"https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2"`, `"https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjx4wXiWtFCc.woff2"`, `"https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2"`, `"https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2"`, `"https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKofINeaBTMnFcQ.woff2"`, `"https://fonts.gstatic.com/s/worksans/v24/QGYsz_wNahGAdqQ43Rh_fKDptfpA4Q.woff2"`, `"https://fonts.gstatic.com/s/sourcesans3/v19/nwpStKy2OAdR1K-IwhWudF-R3w8aZejf5Hc.woff2"`, `"https://fonts.gstatic.com/s/ibmplexsans/v23/zYXzKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1syxeKYbSB4Zh.woff2"`, `"https://fonts.gstatic.com/s/merriweather/v33/u-4e0qyriQwlOrhSvowK_l5UcA6zuSYEqOzpPe3HOZJ5eX1WtLaQwmYiSeqqJ-mXq1Gi.woff2"`, `"https://fonts.gstatic.com/s/playfairdisplay/v40/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2"`, `"https://fonts.gstatic.com/s/instrumentserif/v5/jizBRFtNs2ka5fXjeivQ4LroWlx-6zUTjnTLgNs.woff2"`, `"https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbV2o-flEEny0FZhsfKu5WU4xD7OwGtT0rU.woff2"`, `"https://fonts.gstatic.com/s/dmmono/v16/aFTU7PB1QTsUX8KYthqQBK6PYK0.woff2"`, `"https://fonts.gstatic.com/s/geistmono/v6/or3nQ6H-1_WfwkMZI_qYFrcdmhHkjko.woff2"`): The url the named family is served from, exactly as `GET /templates/fonts` reports it. Any other url is a 422: a font file is fetched by the reader when the mail is opened, so an arbitrary host would be told who opened what and when, whatever the workspace has tracking set to.
  - `format` (`string`, required, one of `"woff2"`, `"woff"`, `"truetype"`, `"opentype"`, `"embedded-opentype"`, `"svg"`)
- `fontWeight` (`string | integer`): A number, a keyword (`normal`, `bold`, `bolder`, `lighter`), or two numbers for a variable range, as in `100 900`.
- `fontStyle` (`string`, one of `"normal"`, `"italic"`, `"oblique"`)

#### `TemplateFontCatalogueEntry`

`object`

One font this API will serve inside a template. The catalogue is the whole of what `fonts[].webFont.url` accepts.

- `object` (`string`, one of `"template_font"`)
- `family` (`string`): The family name to put in `fonts[].fontFamily` and in a block's `font-family`.
- `fallback` (`string`): The email safe family to put in `fallbackFontFamily`. It is what a client that refuses the download renders.
- `stack` (`string`): The whole CSS font stack, family first and fallbacks behind it, ready to drop into a `font-family` style.
- `weight` (`string`): What the file carries: one weight, as in `400`, or two numbers for a variable range, as in `100 900`. Asking for a weight outside it makes the client synthesise one.
- `format` (`string`, one of `"woff2"`)
- `url` (`string`): The url to put in `fonts[].webFont.url`. No other url is accepted for this family.

#### `TemplateFontList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`TemplateFontCatalogueEntry[]`)

#### `TemplateImage`

`object`

- `object` (`string`, required, one of `"template_image"`)
- `id` (`string`, required)
- `url` (`string`, required, format `uri`): The public address of the image.
- `size` (`integer`, required): Bytes, as stored.

#### `TemplateImageList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`TemplateImage[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): An opaque cursor for the next page, or null on the last page. Pass it back unchanged.

#### `TemplateList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Template[]`)
- `hasMore` (`boolean`)
- `nextCursor` (`string`, nullable)

#### `TemplatePreview`

`object`

- `object` (`string`, one of `"template_preview"`)
- `templateId` (`string`)
- `version` (`integer`): Which revision was rendered, resolved rather than echoed, so a preview that named no version records what "published" meant at that moment.
- `subject` (`string`): With its placeholders already filled.
- `html` (`string`): Byte for byte what a send with these values would put in the message.
- `text` (`string`): The `text/plain` alternative, from the same render.
- `warnings` (`object[]`): What is still empty. A preview reports these and renders anyway; a send refuses on the same condition. Empty here is the check worth putting in CI.
  - `code` (`string`): `unfilled_placeholder` today. Treat an unrecognised code as a warning.
  - `key` (`string`)

#### `TemplateProp`

`object`

- `key` (`string`): A letter followed by letters, digits or underscores. This is what `{{key}}` in the subject or the body refers to, and what a send names in `props` or `slots`.
- `label` (`string`): For whoever fills it in. Never rendered into the message.
- `kind` (`string`, one of `"text"`, `"url"`, `"image"`): How a supplied value is escaped when it lands in the body. `text` becomes a React child, so React escapes it; `url` and `image` are parsed and scheme-checked before they reach an `href` or a `src`. The kind is declared rather than sniffed from the value, because a string that happens to start with `https:` is not evidence that the author meant it to be followed.
- `required` (`boolean`): A required prop missing at send is a 422 and no mail leaves. That refusal is the most valuable rule in the feature: the alternative is a message going out with a blank where the order number should be, which nothing reports, nobody notices, and no one can recall.
- `default` (`string`, nullable): Used when the caller names nothing. Null means there is no fallback: either the prop is required and its absence is refused, or it renders empty.

#### `TemplateRender`

`object`

- `object` (`string`, one of `"template_render"`)
- `subject` (`string`): With its placeholders filled, or marked when asked.
- `html` (`string`): What a send of this body would put in the message. Nothing was stored to produce it, so this is the call for checking a design in CI before it is a template.
- `text` (`string`): The `text/plain` alternative, from the same render.
- `warnings` (`object[]`): The declared keys still empty. This endpoint reports them and renders anyway, exactly as a preview does, and a send refuses on the same condition.
  - `code` (`string`): `unfilled_placeholder` today. Treat an unrecognised code as a warning.
  - `key` (`string`)

#### `TemplateSend`

`object`

- `object` (`string`, one of `"template_send"`)
- `id` (`string`): The render id, `tplr_` + hex. Not the message id.
- `version` (`integer`): The version this message rendered.
- `source` (`string`)
- `subject` (`string`): As it went out, with the placeholders already filled.
- `createdAt` (`string`, format `date-time`)
- `recipients` (`string[]`)
- `matched` (`boolean`): Whether this render was paired with a tracked message. False means nothing can ever be reported about it, which is not the same as nobody opening it.
- `opens` (`boolean`): Whether the message asked to be tracked for opens.
- `clicks` (`boolean`)
- `openCount` (`integer`): What actually happened, as opposed to what was asked for.
- `clickCount` (`integer`)

#### `TemplateSendList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`TemplateSend[]`)
- `total` (`integer`): Rows matching the filters, not rows on this page.
- `page` (`integer`)
- `pageSize` (`integer`)

#### `TemplateSlot`

`object`

- `key` (`string`): A letter followed by letters, digits or underscores. This is what `{{key}}` in the subject or the body refers to, and what a send names in `props` or `slots`.
- `label` (`string`): For whoever fills it in. Never rendered into the message.
- `kind` (`string`, one of `"text"`, `"url"`, `"image"`): How a supplied value is escaped when it lands in the body. `text` becomes a React child, so React escapes it; `url` and `image` are parsed and scheme-checked before they reach an `href` or a `src`. The kind is declared rather than sniffed from the value, because a string that happens to start with `https:` is not evidence that the author meant it to be followed.
- `default` (`string`): What renders when nobody overrides it. Never null. A slot always has something to fall back to, which is the difference between it and a prop.

#### `TemplateStarter`

`object`

- `object` (`string`, one of `"template_starter"`)
- `slug` (`string`): The handle to send as `starter` on a create or a content replacement. It names a design shipped with the product, never workspace data, so it is stable across workspaces and keys.
- `name` (`string`)
- `description` (`string`)
- `category` (`string`, one of `"account"`, `"commerce"`, `"notify"`, `"marketing"`): How the console groups the picker. Nothing behaves differently per category.
- `subject` (`string`): The subject the starter comes with, placeholders and all.
- `slots` (`TemplateSlot[]`): What the starter declares for an author to fill.
- `props` (`TemplateProp[]`): What the starter declares for a send to fill. A template made from it starts with exactly these, and they are yours to change afterwards.

#### `TemplateStarterDetail`

`object`

- `object` (`string`, one of `"template_starter"`)
- `slug` (`string`): The handle to send as `starter` on a create or a content replacement. It names a design shipped with the product, never workspace data, so it is stable across workspaces and keys.
- `name` (`string`)
- `description` (`string`)
- `category` (`string`, one of `"account"`, `"commerce"`, `"notify"`, `"marketing"`): How the console groups the picker. Nothing behaves differently per category.
- `subject` (`string`): The subject the starter comes with, placeholders and all.
- `slots` (`TemplateSlot[]`): What the starter declares for an author to fill.
- `props` (`TemplateProp[]`): What the starter declares for a send to fill. A template made from it starts with exactly these, and they are yours to change afterwards.
- `document` (`object`): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
  - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
  - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
  - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
  - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
  - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
  - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
  - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
- `preview` (`string`): The starter rendered to HTML with every undefaulted prop left visible as `{{key}}`, which is what the console shows in its picker. Rendered once per process, so asking repeatedly costs nothing.

#### `TemplateStarterList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`TemplateStarter[]`)

#### `TemplateVersion`

`object`

- `object` (`string`, one of `"template_version"`)
- `id` (`string`): `tplv_` + 24 hex. Not the template id.
- `templateId` (`string`)
- `version` (`integer`): Counts from 1, per template. This is what a send pins.
- `state` (`string`, one of `"draft"`, `"published"`): There is exactly one draft at a time and it is always the highest version. Editing writes into it in place; publishing freezes it and the next edit mints the one after.
- `engine` (`string`, one of `"blocks"`, `"html"`)
- `subject` (`string`)
- `slots` (`TemplateSlot[]`)
- `props` (`TemplateProp[]`)
- `document` (`object`, nullable): The body of a `blocks` template: the block tree plus the page settings wrapped around it. An `html` template has none of this and carries `html` instead. It REPLACES wholesale. A PATCH that sends `document` sends the whole document, so a call that rebuilds `body` and omits `fonts` drops the fonts. Read the head with `GET /templates/{id}` (or an older revision with `GET /templates/{id}/versions/{version}`), change what you mean to change, and post the result back.
  - `body` (`object[]`, required, up to 500 items): The blocks, in order, 500 at most across the whole tree. Each is an object with a `kind` of Section, Row, Column, Container, Text, Heading, Button, Link, Img, Hr, Markdown, CodeBlock, CodeInline and the fields that kind takes; Section, Row, Column and Container nest through their own `children`, 8 deep at most.
  - `preview` (`string`, up to 256 characters): Preview text: the line most mail clients show after the subject in the list. Left out, the client picks the first words of the body for you, which is usually a greeting.
  - `tailwind` (`boolean`, default `false`): Read Tailwind class names on the blocks and inline them at publish. Off by default, because a template written without them gains nothing and pays the compile.
  - `fonts` (`TemplateFont[]`, up to 8 items): Web fonts to load, 8 at most. This is the only place fonts are declared; a `font-family` on a block refers to a family named here or to one already on the reader's machine.
  - `style` (`object`): Page style for the whole message, as a CSS property bag in camelCase or kebab-case, for example `{ "backgroundColor": "#f5f5f5" }`. Only the properties mail clients honour are accepted, and a rejected one is a 422 naming itself and listing what is allowed.
  - `slots` (`TemplateSlot[]`): The same list as the top-level `slots` on a create or a PATCH. Sending both is allowed and the top-level one wins.
  - `props` (`TemplateProp[]`): The same list as the top-level `props`, with the same precedence.
- `html` (`string`, nullable): The markup exactly as it was submitted, for the `html` engine, and not what goes out: sanitising happens at publish, against the compiled copy. Returned where `document` is.
- `publishedAt` (`string`, nullable, format `date-time`)
- `createdAt` (`string`, format `date-time`)

#### `TemplateVersionList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`TemplateVersion[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): An opaque cursor for the next page, or null on the last page. Pass it back unchanged.

#### `Tracking`

`object`

Every `/tracking` endpoint returns this whole, its list included. It arrives trimmed in exactly one place, under `tracking` on a `GET /emails` row, where it is the counts half only: `opens`, `clicks`, `opened`, `clicked`, `openCount`, `clickCount` and `firstOpenAt`. A page of fifty sends each carrying its recipients and its links is a report nobody asked to have expanded. The trimmed form has no `id` on it either, so `/emails/{id}/tracking` rather than `/tracking/{id}` is the way back to the rest of it.

- `object` (`string`, one of `"tracking"`): Present when the report is the whole response body. Absent under an email's `tracking` field, which is part of that email rather than a resource in its own right.
- `id` (`string`): The tracking record, `tmsg_` + 24 hex. Not the message id and not the send id.
- `sendId` (`string`, nullable): The `msg_` this went out as, when the send service handled it. Null for mail the mailbox agent sent on its own behalf, which is most composer, MCP and assistant traffic. Those messages do get a send record, but nothing links this tracking row to it. Tracking covers the mailbox rather than only the traffic that came through this API.
- `threadId` (`string`, nullable)
- `messageId` (`string`, nullable): RFC 5322 Message-ID. Not a correlation key. The header is rewritten on the way out. Correlate on `id`.
- `subject` (`string`, nullable)
- `from` (`string`)
- `source` (`string`, one of `"api"`, `"oauth"`, `"composer"`, `"mcp"`, `"ai"`, `"form"`)
- `sentAt` (`string`, nullable, format `date-time`)
- `opens` (`boolean`): What was APPLIED to this message, resolved when it was sent from the setting of the address it was sent from (its own, else its domain catch-all's, else off, while a broadcast copy is on unless the broadcast or its address turned it off) and any per-send override, not what is switched on now. Turning tracking on today does not make yesterday's mail start reporting, and a report that implied otherwise would read as "nobody opened it".
- `clicks` (`boolean`): As `opens`, for link rewriting. The two are independent switches.
- `opened` (`boolean`)
- `clicked` (`boolean`)
- `attributable` (`boolean`): Whether every reading on this message can be pinned to a named recipient. False as soon as an unattributed copy has activity of its own, which is what happens whenever one body went to the whole list rather than a separate one per person. This is the flag that decides whether "Bob has not opened it" is a sentence a client is entitled to write, or whether all it may say is that somebody did. `recipients` carries the same fact one row at a time, and one row at a time is where it gets missed.
- `openCount` (`integer`): Opens that looked like a person, with repeat fetches within thirty seconds collapsed. A preview pane redrawing is not a second reading. Through Gmail this is a floor and not a total: its proxy fetches the image once and caches it, so later readings never reach us.
- `clickCount` (`integer`): Counted clicks. Stronger evidence than an open, and worth weighting as such: images are blocked far more often than links go unfollowed, so a message with clicks and no opens was certainly read.
- `openCountRaw` (`integer`): Every open hit, the automated ones included. `openCountRaw - openCount` is everything that was filtered out: Apple Mail Privacy Protection and corporate link scanners, which fetch on delivery whether or not a person ever looks, and alongside them the repeat fetches collapsed by the thirty-second window. Both are recorded and neither is counted, because discarding them outright would leave a gap in the log that nothing could explain. Do not read the difference as a machine count on its own. A message reopened twice in a minute lands in it too.
- `clickCountRaw` (`integer`): As `openCountRaw`, for clicks.
- `firstOpenAt` (`string`, nullable, format `date-time`)
- `lastOpenAt` (`string`, nullable, format `date-time`)
- `firstClickAt` (`string`, nullable, format `date-time`)
- `lastClickAt` (`string`, nullable, format `date-time`)
- `recipients` (`object[]`): One entry per tracked copy, which is not always one entry per person. Absent only from the trimmed form on a `GET /emails` row; every `/tracking` response carries it, list included.
  - `email` (`string`, nullable): Null where the bytes could not be varied per person: an encrypted message, one too large to rebuild for each recipient, or a fallback carrier that takes the whole recipient list in a single call. The reading is real; which of the recipients did it is not knowable, and the only honest rendering is "someone on this message", never a name chosen out of the list.
  - `kind` (`string`, nullable, one of `"to"`, `"cc"`, `"bcc"`)
  - `attributed` (`boolean`): False on exactly the rows described above. Branch on this rather than on `email` being a string, and show nothing where it is false: attributing an unattributed open to a named recipient invents evidence about a specific person.
  - `openCount` (`integer`)
  - `clickCount` (`integer`)
  - `firstOpenAt` (`string`, nullable, format `date-time`)
  - `lastOpenAt` (`string`, nullable, format `date-time`)
  - `firstClickAt` (`string`, nullable, format `date-time`)
  - `lastClickAt` (`string`, nullable, format `date-time`)
- `links` (`object[]`): The rewritten links, in the order they appeared in the message. Only links in the new part of the body are here: the quoted history under a reply belongs to whoever wrote it, and routing their URLs through our redirector would both rewrite their message and record the recipient "clicking" something we did not put there. Repeated destinations share one entry, because a campaign page linked from a header image, a button and a footer is one question asked three times. Absent only from the trimmed form on a `GET /emails` row.
  - `id` (`string`)
  - `url` (`string`): Where it actually goes: the original href.
  - `label` (`string`, nullable): The text the link read as in the message, where it had any. A bare URL rarely tells the sender which of five links somebody followed.
  - `clickCount` (`integer`)
  - `clickCountRaw` (`integer`)

#### `UploadedTemplateImage`

`object`

- `object` (`string`, required, one of `"template_image"`)
- `id` (`string`, required)
- `url` (`string`, required, format `uri`): The public address of the image.
- `size` (`integer`, required): Bytes, as stored.
- `width` (`integer`): Pixels, as stored.
- `height` (`integer`): Pixels, as stored.
