Templates
Reusable message bodies with {{variable}} substitution. Scoped to an org, keyed by slug.
Dashboard CRUD
Internal API: /api/internal/templates (session auth, org-scoped).
Create
POST /api/internal/templates
{
"slug": "welcome-email",
"subject": "Welcome to {{company}}",
"html": "<p>Hi {{name}}, thanks for signing up.</p>",
"text": "Hi {{name}}, thanks for signing up.",
"channel": "email"
}
slug— lowercase letters, digits, dashes; unique per org.channel—emailorsms. SMS templates usetextonly;subjectis ignored by the SMS send path.html/text— either or both. For email, at least one must render non-empty.
409 if slug collides within org.
List / Delete
GET /api/internal/templates
DELETE /api/internal/templates?id=tpl_...
Preview
Render the template with sample variables without sending. Available to any session member.
POST /api/internal/templates/preview
{
"id": "tpl_...", // or "slug": "welcome-email"
"variables": { "name": "Fareed" }
}
Response:
{
"id": "tpl_...",
"slug": "welcome-email",
"channel": "email",
"subject": "Welcome, Fareed",
"html": "<p>Hi Fareed, ...</p>",
"text": "Hi Fareed, ..."
}
Test send
Render + dispatch a real test message via the provider. Tagged template-test and written to messages with metadata.source = "test_send" so test sends are easy to filter out of usage dashboards.
POST /api/internal/templates/test-send
{
"id": "tpl_...",
"from": "hello@yourdomain.com",
"to": "you@example.com",
"variables": { "name": "Fareed" }
}
Response: { "id": "msg_...", "status": "sent" }.
502 is returned if the provider call fails; the error message is surfaced to the client.
Both endpoints are also surfaced in the dashboard (/overview/templates → Preview / test) — HTML renders in a sandboxed iframe.
Test sends reject viewers — test-send is a real provider call and is gated on requireWriteSession.
Syntax
Variables are {{name}}, and dotted paths traverse nested objects ({{customer.address.city}}). A flat key wins over a path, so a metadata key that literally contains a dot still resolves.
Blocks
{{#each items}}…{{/each}} |
Iterate an array. Inside, an object item's own keys resolve directly ({{name}}), {{this}} is the item itself (useful for arrays of strings), and {{@index}} is its position. Names the item does not carry fall back to the outer scope, so {{brand}} still works inside the loop. |
{{#if x}}…{{else}}…{{/if}} |
Conditional. {{else}} is optional. |
{{#unless x}}…{{/unless}} |
Inverted conditional. |
Blocks nest.
An empty array is falsy. {{#if items}} is false for [], because "if there are items, show the table" is what an author writing that means — and it is the most common conditional in a transactional template. Empty string and 0 are falsy too.
What is deliberately absent
No expressions, no helpers, no arbitrary lookups, no partials. Templates are customer-authored and rendered on our servers, so the language is kept to one where "what can this do" is answerable by reading src/lib/api/template.ts.
Malformed templates
An unclosed or mismatched block renders its own braces as literal text rather than throwing. This runs on the send path: a typo should mail something imperfect that shows the author what happened, not fail the send and drop the rest of the email.
Required variables
missing_variables (the structured 422 on send) is block-aware:
{{#each items}}requiresitems.- Names used inside a block are not required — they resolve against each item.
{{#if coupon}}does not requirecoupon. Demanding it would defeat the conditional.- An audience blast checks every recipient before anything is scheduled. A placeholder that resolves for no recipient (a typo, usually) refuses the whole blast with the same
422 TEMPLATE_MISSING_VARIABLES. One that only some contacts can resolve renders empty for the rest (details).
Using in a send
POST /api/v1/emails
{
"from": "hi@yourdomain.com",
"to": ["user@example.com"],
"template": "welcome-email",
"variables": { "name": "Fareed", "company": "Acme" }
}
Rendering rules (src/lib/api/template.ts):
{{key}}→ variable lookup invariables; dotted paths ({{order.shipping.city}}) walk nested objects.- Whitespace allowed:
{{ key }}works. - A placeholder the request does not supply is refused before sending with
422 TEMPLATE_MISSING_VARIABLES(see Required variables). A variable that is present butnull, or an array or object used as a plain{{key}}, renders as an empty string. - Each rendered part (subject, HTML, text) has three caps. Its
{{#each}}blocks may run at most 5,000 iterations in total, across all loops in it (nested loops multiply), and may produce at most 2,000,000 characters. The whole part, loops or not, may be at most 10,000,000 characters. Past any of them the send is refused with413 PAYLOAD_TOO_LARGE; it is never sent cut short. Audience blasts have their own per-recipient and per-blast size caps (rendered size). - Values rendered into
htmlare HTML-escaped;subjectandtextare not. - Blocks (
{{#each}},{{#if}},{{#unless}}) as in Syntax. No expressions and no helpers.
Variables
variables takes any JSON value per key. Strings, numbers, booleans and null
substitute directly, arrays feed {{#each}} and objects feed dotted paths. The
same rule applies on POST /v1/emails, POST /v1/sms, both audience send
routes, and the dashboard's preview and test send. Batch items
(/emails/batch, /sms/batch) do not support template, so variables on an
item has no effect, although it is still validated by the same rule:
{
"template": "order-shipped",
"variables": {
"customer": { "first_name": "Ada" },
"order": { "id": "1042", "shipping": { "city": "Lisbon" } },
"items": [
{ "name": "Widget", "qty": 2 },
{ "name": "Gadget", "qty": 1 }
]
}
}
Hi {{customer.first_name}}, order {{order.id}} is on its way to {{order.shipping.city}}.
{{#each items}}- {{qty}} x {{name}}
{{/each}}
Two limits apply. Past either one the request is refused with
422 VALIDATION_ERROR, and the message names the offending path:
- Depth. A value may nest at most 10 levels below its top-level key,
counting each array and each object as one level.
{ "items": [{ "name": "…" }] }is two. That is the renderer's own block-nesting limit, so no template could reach anything deeper. - Size. The whole
variablesmap must serialize to at most 64 KB (65,536 bytes of UTF-8 JSON).
Non-finite numbers are refused too. JSON has no way to send one.
In an audience blast
POST /v1/audiences/{id}/send renders once per contact. On top of the
request's variables, every recipient gets its own contact fields:
{{contact.id}}, {{contact.name}}, {{contact.first_name}},
{{contact.email}}, {{contact.phone}}, and flat {{name}}, {{first_name}},
{{email}} and {{phone}}. The contact's metadata keys are available too.
Precedence, lowest first: contact fields, the request's variables, then the
contact's metadata. A contact object in variables merges into the seeded
one field by field. See
Per-recipient variables.
Overrides
If the send body supplies subject / html / text / body directly, those override the template's rendered versions. This lets you override a single field while keeping the rest.
Schema
templates table (src/lib/db/schema/templates.ts):
| Column | Type | Notes |
|---|---|---|
id |
text PK | tpl_... |
org_id |
FK | scope |
slug |
text | unique per org |
subject |
text | used for email |
html |
text | optional |
text |
text | required for sms if used, optional for email |
channel |
text | email / sms |
created_at/updated_at |
timestamps |