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:
| Field | Rule |
|---|---|
bodyHtml | Every substituted value is HTML-escaped: &, <, >, ", ', and also ` and =. |
bodyText | No escaping — it is not markup. |
subject | No 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 =, 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 ajavascript:value survives escaping. Validate URLs you interpolate. - It does not cover
<style>or<script>. CSSurl()and@importneed 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
bodyHtmlis sent as written, exactly ashtmlonPOST /emailsis.
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:
| File | Holds |
|---|---|
<slug>.json | name, subject, variablesSchema |
<slug>.html | bodyHtml |
<slug>.txt | bodyText — 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:
pushnever 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, orDELETE /api/v1/templates/:slug.- A missing
<slug>.txtmeans "no opinion", not "clear the text body". An absent file is ambiguous — never pulled, gitignored, lost — sopushreports 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.