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 — email or sms. SMS templates use text only; subject is ignored by the SMS send path.
  • subject — at most 998 characters (one RFC 5322 header line), the same cap as POST and PATCH /v1/templates; a longer one is refused with 422. Required (non-empty) for an email template only; an SMS template may omit it and is stored with an empty subject, as POST /v1/templates does.
  • 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 with 422 VALIDATION_ERROR. The same cap applies to POST and PATCH /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}} requires items.
  • Names used inside a block are not required — they resolve against each item.
  • {{#if coupon}} does not require coupon. 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 in variables; 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 but null, 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 with 413 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 html are HTML-escaped; subject and text are 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 variables map 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