Templates
Build the HTML/text content, {{variable}} placeholders, locales, and attachments a trigger or campaign sends.
A template is the content a trigger or campaign 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. Writes need EDITOR+.
Create a template
Section titled “Create a template”POST https://retidal.com/api/projects/{projectId}/email/templates{ "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": []}namestringrequired1–120 characters.
categorystringtransactional | marketing. Default marketing. Marketing sends are blocked project-wide until
compliance settings are configured — set this correctly.
subjectstringrequired1–200 characters. Supports {{variable}} substitution.
previewTextstringOptional inbox preview snippet, ≤200 characters, nullable.
htmlBodystringrequired1–200,000 characters.
textBodystringOptional plain-text alternative, ≤200,000 characters, nullable.
variablesarrayUp to 100 variable names, 1–80 characters each.
statusstringdraft | active | archived on create (default active). See the note below on what you can change
later.
attachmentsarrayUp to 3 email_assets IDs, combined size ≤7 MB. See Attachments below.
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.
Updating a template
Section titled “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:
statuscan only move betweendraftandactive. You cannot usePATCH /api/projects/{projectId}/email/templates/{templateId}to archive a template, and calling it on an already-archivedtemplate always returns400. 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 withPATCHor 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
Section titled “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
Section titled “Template report”GET /api/projects/{projectId}/email/templates/{templateId}/reportDelivery-effect counts and rates for everything sent from this template.
Attachments
Section titled “Attachments”Two independent attachment mechanisms exist, and they don’t mix:
| Static attachment | Dynamic attachment | |
|---|---|---|
| Configured on | Template (attachments: [assetId, ...]) |
Trigger (dynamicAttachments, see Triggers) |
| 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
Section titled “Asset library”POST /api/projects/{projectId}/email/assets multipart/form-data, field name "file"GET /api/projects/{projectId}/email/assetsDELETE /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 with422,Content-Typeheader is not trusted. DELETE /api/projects/{projectId}/email/assets/{id}on an asset still referenced by a template returns409with the list of referencing templates.- Download URLs from
POST /api/projects/{projectId}/email/assets/{id}/download-urlare short-lived signed links (5-minute TTL), minted withPOST(notGET— a capability URL shouldn’t be something a prefetcher or cache can trigger). If the signing key isn’t configured on the worker, minting returns503rather than an unsigned URL.
Dynamic attachment domain allowlist
Section titled “Dynamic attachment domain allowlist”GET /api/projects/{projectId}/email/settings/attachment-allowlist → { domains: [...] }PUT /api/projects/{projectId}/email/settings/attachment-allowlist (ADMIN) { domains: [...] }, ≤50 entriesAn 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.
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
Section titled “Next steps”Fire this template when an event matches, with rate limits and dynamic attachments.
Send this template to a filtered audience in bulk.