---
title: Templates
description: Build the HTML/text content, {{variable}} placeholders, locales, and attachments a trigger or campaign sends.
---

A template is the content a [trigger](/docs/email/triggers) or [campaign](/docs/email/campaigns) actually
sends. It holds a subject, an HTML body, an optional plain-text body, a list of `{{variable}}` names, and
(optionally) static attachments.

Authentication for every endpoint on this page is the console session cookie — see
[Providers § Authentication](/docs/email/providers#authentication). Writes need EDITOR+.

## Create a template

<CodeGroup>
```text title="Endpoint"
POST https://retidal.com/api/projects/{projectId}/email/templates
```
```json title="Request body"
{
  "name": "Welcome email",
  "category": "transactional",
  "subject": "Welcome, {{ name }}",
  "htmlBody": "<p>Hi {{ name }}, thanks for signing up.</p>",
  "textBody": "Hi {{ name }}, thanks for signing up.",
  "variables": ["name"],
  "status": "active",
  "attachments": []
}
```
</CodeGroup>

<ParamField name="name" type="string" required>
  1–120 characters.
</ParamField>
<ParamField name="category" type="string">
  `transactional` | `marketing`. Default `marketing`. Marketing sends are blocked project-wide until
  [compliance settings](/docs/email/campaigns#compliance-settings) are configured — set this correctly.
</ParamField>
<ParamField name="subject" type="string" required>
  1–200 characters. Supports `{{variable}}` substitution.
</ParamField>
<ParamField name="previewText" type="string">
  Optional inbox preview snippet, ≤200 characters, nullable.
</ParamField>
<ParamField name="htmlBody" type="string" required>
  1–200,000 characters.
</ParamField>
<ParamField name="textBody" type="string">
  Optional plain-text alternative, ≤200,000 characters, nullable.
</ParamField>
<ParamField name="variables" type="array">
  Up to 100 variable names, 1–80 characters each.
</ParamField>
<ParamField name="status" type="string">
  `draft` | `active` | `archived` on create (default `active`). See the note below on what you can change
  later.
</ParamField>
<ParamField name="attachments" type="array">
  Up to 3 `email_assets` IDs, combined size ≤7 MB. See [Attachments](#attachments) below.
</ParamField>

<Note title="Variables are plain substitution, not a template language">
  `{{ key }}` is replaced with a literal string value — there is no MJML, no Handlebars, no conditionals or
  loops. At render time, an event property with the same name takes priority over a matching user trait.
</Note>

## Updating a template

```
PATCH /api/projects/{projectId}/email/templates/{templateId}
```

This is a partial update — every field above is optional and only the fields you send change. Two
constraints to know before you build an editing UI around it:

- **`status` can only move between `draft` and `active`.** You cannot use `PATCH /api/projects/{projectId}/email/templates/{templateId}`
  to archive a template, and calling it on an already-`archived` template always returns `400`. If you need a
  template gone, build a replacement and repoint your triggers/campaigns at it.
- **There is no `DELETE`.** A template that's misconfigured has to be fixed with `PATCH` or superseded by a
  new one — it never disappears from the list.

Confirm subject/htmlBody/variables with whoever's asking before you create a template — since the create
call is the only way to get a *fresh* template ID, trial-and-error creates orphaned records that clutter the
template list.

## Locales

A template can carry per-locale variants (`templates/{templateId}/locales`), each independently draft or
published:

| Method | Path | Role | Notes |
| --- | --- | --- | --- |
| GET | `api/projects/{projectId}/email/templates/{templateId}/locales` | Viewer | Lists every draft + published variant. |
| PUT | `api/projects/{projectId}/email/templates/{templateId}/locales/{locale}` | Editor | Manual upsert; always lands as `status: "published"`, `origin: "manual"`. |
| DELETE | `api/projects/{projectId}/email/templates/{templateId}/locales/{locale}` | Editor | Removes the variant. |
| POST | `api/projects/{projectId}/email/templates/{templateId}/locales/{locale}/ai-draft` | Admin | Workers AI-generated draft, no request body. Always lands `status: "draft"`, `origin: "ai"`. `409` if the locale is already published; `503` if the Workers AI binding isn't configured. |
| POST | `api/projects/{projectId}/email/templates/{templateId}/locales/{locale}/publish` | Editor | `draft → published` only, no request body. `400` on a non-draft locale. |

`PUT`/the AI-draft body carries `subject` (1–200 chars) and `htmlBody` (1–200,000 chars), plus optional
`textBody` (≤200,000 chars, nullable).

## Template report

```
GET /api/projects/{projectId}/email/templates/{templateId}/report
```

Delivery-effect counts and rates for everything sent from this template.

## Attachments

Two independent attachment mechanisms exist, and they don't mix:

| | Static attachment | Dynamic attachment |
| --- | --- | --- |
| Configured on | **Template** (`attachments: [assetId, ...]`) | **Trigger** (`dynamicAttachments`, see [Triggers](/docs/email/triggers#dynamic-attachments)) |
| Source | Pre-uploaded file in the asset library | A URL read from a property on the triggering event |
| Limit | ≤3 files, ≤7 MB combined | ≤3 entries per trigger |
| Good for | Logo, terms PDF — the same file every time | A different invoice/receipt per recipient |
| Works with campaigns | Yes (a campaign reads whatever's on its template) | No — campaigns have no `dynamicAttachments` field |

### Asset library

```
POST   /api/projects/{projectId}/email/assets                          multipart/form-data, field name "file"
GET    /api/projects/{projectId}/email/assets
DELETE /api/projects/{projectId}/email/assets/{id}
POST   /api/projects/{projectId}/email/assets/{id}/download-url        → { url, exp }
```

- Upload (`POST /api/projects/{projectId}/email/assets`) is EDITOR+, ≤5 MB per file. Type is sniffed from **magic bytes**
  (png/jpg/webp/pdf only) — a renamed file with the wrong extension is rejected with `422`,
  `Content-Type` header is not trusted.
- `DELETE /api/projects/{projectId}/email/assets/{id}` on an asset still referenced by a template returns `409` with the list of
  referencing templates.
- Download URLs from `POST /api/projects/{projectId}/email/assets/{id}/download-url` are short-lived signed links (5-minute TTL),
  minted with `POST` (not `GET` — a capability URL shouldn't be something a prefetcher or cache can
  trigger). If the signing key isn't configured on the worker, minting returns `503` rather than an
  unsigned URL.

### Dynamic attachment domain allowlist

```
GET /api/projects/{projectId}/email/settings/attachment-allowlist          → { domains: [...] }
PUT /api/projects/{projectId}/email/settings/attachment-allowlist   (ADMIN)  { domains: [...] }, ≤50 entries
```

<Danger title="An empty allowlist disables dynamic attachments — it does not mean 'allow everything'">
  If you configure a trigger's `dynamicAttachments` but never add a domain to this allowlist, every fetch
  attempt fails closed with `attachment_fetch_failed`. Add the domain that will host the invoice/receipt URL
  before you wire up the trigger, not after.
</Danger>

Fetching a dynamic attachment is guarded against SSRF: allowlisted domains only, HTTPS only, no IP literals
or internal/metadata hosts, no redirects followed, a streamed 5 MB cutoff, and a request timeout that covers
the whole body read — plus the same magic-byte type check as uploads. Whatever bytes are fetched are written
to a R2 audit copy before the email goes out; if that write fails, the attachment is dropped and the rest of
the email still sends.

## Next steps

<CardGroup cols={2}>
  <Card title="Event triggers" href="/docs/email/triggers">
    Fire this template when an event matches, with rate limits and dynamic attachments.
  </Card>
  <Card title="Campaigns" href="/docs/email/campaigns">
    Send this template to a filtered audience in bulk.
  </Card>
</CardGroup>
