Contents

Templates

A template is a stored subject and body with {{ variable }} placeholders. Send one by naming its slug instead of passing html:

const { id } = await sendsprite.emails.send({
  from: "Acme <hello@mail.acme.com>",
  to: ["ada@example.com"],
  template: "welcome",
  variables: { name: "Ada" },
});

Over REST:

curl -X POST "https://sendsprite.com/api/v1/emails" \
  -H "Authorization: Bearer ss_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": ["ada@example.com"],
    "template": "welcome",
    "variables": { "name": "Ada" }
  }'
# → 201 {"id":"em_…"}

The render happens on the server, and the rendered subject and bodies are what is stored and what is sent. template cannot be combined with html or text — exactly one content source — and variables is refused without template. subject is optional when a template supplies one, and a request-level subject overrides the template's.

Creating one is a slug, a name, a subject and an HTML body:

await sendsprite.templates.create({
  slug: "welcome",
  name: "Welcome",
  subject: "Welcome, {{ name }}",
  bodyHtml: "<p>Hi {{ name }}, thanks for signing up.</p>",
  bodyText: "Hi {{ name }}, thanks for signing up.",
  variablesSchema: { variables: [{ name: "name", type: "string" }] },
});
curl -X POST "https://sendsprite.com/api/v1/templates" \
  -H "Authorization: Bearer ss_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "slug": "welcome", "name": "Welcome", "subject": "Welcome, {{ name }}",
        "bodyHtml": "<p>Hi {{ name }}, thanks for signing up.</p>" }'
# → 201 {"id":"tpl_…","slug":"welcome","version":1,…}

A slug is lower-case letters, digits and single dashes, up to 64 characters; a name up to 120; each body up to 5 MB. bodyText is optional and variablesSchema defaults to no declarations. The SDK's templates resource is list(), iterate(), get(slug), create(), update(slug, …), remove(slug) and render(slug, variables) — there is no rename(), for the reason under Versioning.

Managing templates needs a full API key, POST /templates/:slug/render included, because both return template content. Sending with a template does not: a sending_only key calls POST /emails and the server renders. In the dashboard, every team role can manage templates.

The syntax

{{ name }}, and nothing else. Whitespace inside the braces is ignored, and names may be dotted ({{ user.firstName }}, up to four segments) to reach into a nested object. A name is letters, digits and underscores, starting with a letter or an underscore.

There are no helpers, filters, conditionals or loops, and there is no unescaped form — no {{{ }}}, no | safe. A template engine's value is its expression language, and its expression language is its attack surface; this renders your customers' data into HTML that is then emailed, so there is deliberately nothing to sandbox. For anything that needs logic, render it yourself and pass html (the SDK's react option does exactly that).

Substituted text is never re-scanned, so a value containing {{x}} produces the literal {{x}}. Dotted lookups walk own properties only, so {{ a.constructor }} resolves to nothing rather than to a function.

A single field may use at most 500 placeholder occurrences.

Escaping

Escaping is decided by the field, not by you:

FieldRule
bodyHtmlEvery substituted value is HTML-escaped: &, <, >, ", ', and also ` and =.
bodyTextNo escaping — it is not markup.
subjectNo HTML escaping, but the rendered subject is trimmed and then refused if it is empty, longer than 998 characters, or holds a control character.

The backtick and = are escaped because unquoted attributes are ordinary in hand-written email HTML: in <a href={{u}}>, a value of x onmouseover=alert(1) becomes a real event handler without using a single quote character. Escaping = is what actually closes that, because attribute names are not entity-decoded. The cost is that a literal = in visible text is emitted as &#61;, which the mail client decodes back to =.

The subject rule is the one that matters most: a CR or LF in a subject is header injection, and the check on what you send cannot see what a template renders, so it is repeated on the output. The rendered check covers every C0 control character and DEL, matching the rule POST /emails applies to a subject you write yourself.

Three things escaping does not do, and you should know all three:

  • It does not make a URL safe. <a href="{{link}}"> with a javascript: value survives escaping. Validate URLs you interpolate.
  • It does not cover <style> or <script>. CSS url() and @import need no HTML-special character, so a placeholder does not belong inside a style element; inside a script, entities are not decoded, so a value arrives corrupted rather than executed. Neither is a place for a placeholder.
  • It does not sanitise the template. Whatever your team writes into bodyHtml is sent as written, exactly as html on POST /emails is.

Variables

Every placeholder must resolve, or the send is refused with a validation_error naming what is missing. A silent empty string produces "Hi ," at volume, and you find out from a customer rather than from a test.

Values may be strings, numbers or booleans. null, NaN, objects and arrays are refused rather than rendered. An empty or whitespace-only string counts as missing, not as a value — a blank CSV cell is "", and mailing "Hi ," because of one is the failure this rule exists to prevent.

Declare a variable with a default to make it genuinely optional:

{
  "variablesSchema": {
    "variables": [
      { "name": "name", "type": "string", "default": "there" },
      {
        "name": "credits",
        "type": "number",
        "description": "Shown in the header"
      }
    ]
  }
}

default is the only way to make a placeholder optional. There is deliberately no required flag: the renderer refuses every unresolved placeholder, so required: true would be a no-op, and required: false could only mean "render nothing here". To substitute a genuinely empty value, declare default: "" — explicit rather than accidental.

A declared type is enforced against the supplied value at render time and against the default when the template is saved; a name declared twice is refused. A description is shown in the dashboard editor and nowhere else. A template may declare up to 100 variables.

A supplied variables payload is bounded: at most 200 keys (counting nested objects and array items), 2 000 characters per value, and 64 KB serialised. Per-recipient content larger than that is what the html field is for.

Versioning

Every content change bumps version and appends a snapshot. GET /api/v1/templates/:slug returns the 20 most recent versions alongside the template, and Restore in the dashboard applies an old snapshot as a new version — history is append-only, so what was live and when stays readable, and a restore is itself undoable. An update that changes nothing bumps nothing.

Every :slug route also accepts the tpl_… id — the two cannot collide, because a slug admits neither an underscore nor an upper-case letter — so sendsprite.templates.get(created.id) works straight after a create().

slug cannot be changed; PATCH does not accept the field. A live send names a template by slug, so a rename would be a silent outage — create the new one and delete the old.

Deleting a template leaves the emails it produced alone: their stored bodies stay exactly as they were sent, and the link back to the template becomes null.

Writing one: blocks or HTML

The dashboard offers two ways to author a body, and a template remembers which one it was written with.

Design is the same visual editor campaigns use — a palette of blocks, drag-and-drop, columns, and a style panel for the selection. It is what a new template opens in. Placeholders work exactly as they do anywhere else: type {{ name }} into a heading or a paragraph.

HTML is a textarea. It is what you want for markup you already have, for anything the block set does not cover, and for a template that a designer hands you as a file.

Switching Design to HTML keeps the markup the blocks currently produce and turns the visual editor off for that template — a design that no longer produces the stored HTML would overwrite your next hand edit, so it is discarded rather than kept. Switching HTML to Design cannot parse arbitrary markup back into blocks, so it starts a new body; the editor says so before it does anything.

None of this changes the API. body_html is what is stored and what is sent, POST /templates still takes HTML, templates pull still writes an .html file, and a template created through the API simply has no design and opens in HTML mode. The block tree is the dashboard editor's own source and is not part of the REST contract.

One difference worth knowing: a template body carries no unsubscribe footer, whichever way it was written. Campaigns are bulk mail to a list and always append one; a template is the body of a transactional send, and a password reset with an unsubscribe link under it is not a thing anyone wants.

A template built in Design mode carries a theme too — the page and card colours, the width, the font — stored beside the blocks and compiled into body_html on every save. As with campaigns, an absent theme is the renderer's defaults, and neither the theme nor the block tree appears in the REST response: the API deals in HTML.

Sending yourself a test

Send a test on a saved template mails one copy through the ordinary send path, rendered from the stored row with each variable's declared default. It is a real send: it counts against the team's limits, appears in the mail log tagged sendsprite_test, and — in the SES sandbox — only arrives if the recipient address is verified in AWS. Its subject is prefixed with [Test] so it cannot be mistaken for the real thing in an inbox.

The button is disabled while there are unsaved changes, because the test renders the template the server holds, not the one on your screen.

Previewing

const { subject, html, text } = await sendsprite.templates.render("welcome", {
  name: "Ada",
});

Over REST, POST /api/v1/templates/:slug/render with { "variables": { … } } returns { subject, html, text } without sending or storing anything. It runs the same renderer the send path runs, so the preview cannot differ from the send. The variables are not echoed back, and the same payload bounds apply (the request body itself is capped at 256 KB). text is null for a template with no text body.

The dashboard editor calls the same function in your browser, which is why its preview updates as you type. A variable with a declared default previews with that default; one without gets a visible stand-in — {name} for a string, 0 for a number, true for a boolean — so you can see where the value will land. The preview renders inside a sandboxed iframe.

From the CLI

sendsprite templates pull ./emails    # write every template to ./emails
sendsprite templates push ./emails    # create what is missing, update the rest
sendsprite templates push ./emails --dry-run

The directory is flat, three files per template, named by the slug:

FileHolds
<slug>.jsonname, subject, variablesSchema
<slug>.htmlbodyHtml
<slug>.txtbodyText — present only when the template has one

The slug is the identity: it is immutable server-side and already restricted to lower-case letters, digits and dashes, so the file name carries it and no manifest field repeats it.

Two behaviours here are safety properties rather than incidentals:

  • push never deletes a remote template. A template on the instance with no file in the directory is reported and left alone. Deleting is explicit: the dashboard, or DELETE /api/v1/templates/:slug.
  • A missing <slug>.txt means "no opinion", not "clear the text body". An absent file is ambiguous — never pulled, gitignored, lost — so push reports it and leaves the stored text body as it is. There is currently no CLI path to remove a text body; use the dashboard.

Add a .gitattributes to the directory:

* text=auto eol=lf

On Windows with core.autocrlf=true a checked-out body becomes CRLF, and push will correctly — but confusingly — see that as a change to every template.