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.subject— at most 998 characters (one RFC 5322 header line), the same cap asPOSTandPATCH /v1/templates; a longer one is refused with422. Required (non-empty) for an email template only; an SMS template may omit it and is stored with an empty subject, asPOST /v1/templatesdoes.html/text— either or both. For email, at least one must render non-empty. Each may be at most 524,288 characters (512 Ki); a longer one is refused with422 VALIDATION_ERROR. The same cap applies toPOSTandPATCH /v1/templates.
409 if slug collides within org.
List / Edit / Delete
GET /api/internal/templates
PATCH /api/internal/templates/tpl_...
DELETE /api/internal/templates?id=tpl_...
GET pages with limit (default 50, max 100) and cursor; the dashboard
follows next_cursor to the end. PATCH takes any of subject, html and
text, with the create route's bounds; the slug and channel cannot change.
An empty subject is 422 on an email template and accepted on an SMS one.
It is 404 for a template outside the active project and 422 when the body
carries none of the three. Both PATCH and DELETE need a developer or
owner session.
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.
An email test send routes like any live email from its sender domain: the domain's region and fallback region, and its dedicated IP pool when it has one. A domain whose pool is paused answers 409 IP_POOL_PAUSED before anything is metered or sent. (It used to go through the deployment's default region on the shared pool.)
The row is stored with environment: "test", so its delivery, bounce and complaint webhooks carry "environment": "test" like any other test send's, even though this one really reached the provider.
Because it really reached the provider, a test send is metered as live usage: one email, or the rendered body's SMS segments. It runs the same plan-allowance and spend-cap gate as /v1/emails and /v1/sms (429 USAGE_LIMIT_EXCEEDED on a Free org at its allowance, 402 SPEND_CAP_EXCEEDED over the org's cap), and counts toward the period's usage and invoice once the provider accepts it. An SMS test send is limited to 10 segments (422 TEST_SEND_TOO_LONG). On top of that, an organization may test-send 20 times an hour, and each one is a row of test mode's daily allowance.
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 requireDeveloperSession (developer or owner).
Public API
GET / POST /v1/templates and GET / PATCH / DELETE /v1/templates/{id}
(scopes read:templates / write:templates). The request bodies are the
CreateTemplate and UpdateTemplate schemas in /api/openapi.json.
Writes need a live key
Templates have no test-mode copy: a live send and a test send resolve the
same rows by slug. So POST, PATCH and DELETE answer
403 ENVIRONMENT_ESCALATION to a sok_test_* key, and reads stay open to it.
Create and edit templates with a live key; a scoped one (write:templates) is
enough.
Creating is refused as well as editing because a send looks for the
tenant's own template before the org's (tenant precedence). A template
created under a tenant, with tenant_id or through a tenant-bound key,
replaces the org's template of the same slug in every send that tenant
makes, live ones included.
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 four 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, and may take at most 5,000,000 units of work, whether or not it renders anything. A unit is a step of fixed cost: each text run, placeholder and block counts one (again for each{{#each}}iteration, and each iteration one more); looking a name up counts one for every scope it is asked of (the innermost{{#each}}item outward) and one for every key of a dotted path it walks;{{#if}}/{{#unless}}on an object counts four per key the first time it asks of that object; and a value counts one per 16 characters, plus one for every character HTML escaping adds. A real template spends thousands. 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 caps (rendered size, render work). - 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 |