Skip to content

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

POST https://retidal.com/api/projects/{projectId}/email/templates
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": []
}
namestringrequired

1–120 characters.

categorystring

transactional | marketing. Default marketing. Marketing sends are blocked project-wide until compliance settings are configured — set this correctly.

subjectstringrequired

1–200 characters. Supports {{variable}} substitution.

previewTextstring

Optional inbox preview snippet, ≤200 characters, nullable.

htmlBodystringrequired

1–200,000 characters.

textBodystring

Optional plain-text alternative, ≤200,000 characters, nullable.

variablesarray

Up to 100 variable names, 1–80 characters each.

statusstring

draft | active | archived on create (default active). See the note below on what you can change later.

attachmentsarray

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

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.

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

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

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

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
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.
GET /api/projects/{projectId}/email/settings/attachment-allowlist → { domains: [...] }
PUT /api/projects/{projectId}/email/settings/attachment-allowlist (ADMIN) { domains: [...] }, ≤50 entries

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.

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.