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.
  • channelemail or sms. SMS templates use text only; subject is 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/templatesPreview / 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}} 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 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 with 413 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 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