Email template variables done right: declared, typed, versioned and escaped
Published Updated 10 min readBy the Rasket team

What email template variables are
Email template variables are the named, typed slots a stored email template declares — {{name}}, {{seats}} — that each send fills with a value. They are the mechanism behind email personalization: the content lives in the template, the facts about one recipient arrive with the send, and rendering joins the two.
The word declared is the whole difference between a template variable and a string you glued together in application code. A declared variable has a key, a type, and optionally a fallback, and the API checks a send against that declaration before anything is rendered. A value of the wrong type is refused. A key nobody declared is refused. A key the send forgot takes its fallback or is refused. None of those become an email with a bare placeholder in it.
The grammar is deliberately small. A placeholder is {{key}} or {{ key }}, with the key matching ^[A-Za-z0-9_]+$, and there is no logic, no filter and no nesting. Anything conditional belongs in the code that decides which template to send, and the templates page explains why that trade was made.
Typed variables vs string interpolation
The alternative is to build the HTML in your own code — "Hello " + name, or a template literal — and post it as html. It works, and it is where most teams start. The table is what you give up by staying there.
| Declared, typed variables | String interpolation in code | |
|---|---|---|
| Where the content lives | In a versioned template | In a deploy |
| A missing value | Takes the fallback, or the send is refused | Prints undefined, or an empty string |
| A wrong type | 422 before rendering | Whatever toString gives you |
| HTML escaping | Applied to every string in the HTML body | Yours to remember on every field |
| Changing the copy | Publish a new version; no deploy | A code change and a release |
| What a sent email records | The template and the exact version | Nothing beyond the bytes that were sent |
| Rendering elsewhere | Preview and test endpoints | Run the code |
| Best for | Mail whose copy is owned by someone other than the engineer sending it | A one-off message assembled from data only the code has |
The escaping row is the one with teeth. In application code, escaping is a habit, and habits have exceptions. In a template, string values are escaped in the HTML body without anyone deciding to, which is the subject of the last section.
Declaring variables with fallbacks
A template is created as a draft with its content and the variables that content uses. Each variable is { key, type, fallback_value }, with type one of string, number, boolean, object or list, and a version may declare at most 200 of them.
curl https://api.rasket.com/templates \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome", "alias": "welcome", "from": "Acme <orders@send.acme.example>", "subject": "Welcome to Acme, {{name}}", "html": "<p>Hello {{name}}, your workspace has {{seats}} seats.</p>", "text": "Hello {{name}}, your workspace has {{seats}} seats.", "variables": [ { "key": "name", "type": "string", "fallback_value": "there" }, { "key": "seats", "type": "number", "fallback_value": 1 } ] }'The alias is the handle your code will hold: lower-case letters, digits, - and _, at most 63 characters, never a UUID, unique among your live templates and freed when the template is deleted. Every template route and every send accepts either the id or the alias.
The declare-and-use rule
Every {{key}} in subject, html or text must be declared in variables, and every declared variable must appear in at least one of them. Break the rule in either direction and the write is refused with one entry in errors[] per key, so an editor can point at exactly what is wrong. The shape below is fixed: a path and a message per key.
{
"statusCode": 422,
"name": "validation_error",
"message": "Every {{key}} must be declared in variables, and every declared variable must be used.",
"errors": [
{ "path": "html", "message": "{{plan}} is used in html but is not declared in variables." },
{ "path": "variables[1].key", "message": "Variable seats is declared but never used in subject, html or text." }
]
}The rule sounds strict until the first time it saves you. Without it, a typo in a placeholder is an email that says Hello {{nmae}} to a customer; with it, the typo is a 422 in your test run.
Versions: publish, restore, roll back
Creating a template writes version 1 as a draft, and nothing can be sent from it until it is published. Publishing makes the current version the one sends use. The first PATCH on a published template opens a new draft version; later edits write that same draft, and sends keep using the published version until you publish again.
# Edit a published template: this opens draft version 2curl -X PATCH https://api.rasket.com/templates/welcome \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Content-Type: application/json" \ -d '{ "subject": "Welcome aboard, {{name}}" }'
# See which version is current and which is publishedcurl https://api.rasket.com/templates/welcome/versions \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0"
# Make version 2 the one sends usecurl -X POST https://api.rasket.com/templates/welcome/publish \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0"
# Roll back: a new draft (version 3) copied from version 1, then publish itcurl -X POST https://api.rasket.com/templates/welcome/versions/1/restore \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0"The versions list marks two things per entry: current is the version you read and edit, and published is the one sends use. When they differ, retrieving the template shows the draft with has_unpublished_versions set to true.
- Rolling back is a restore followed by a publish. Restoring version 1 does not flip anything live: it copies that version into a new draft, which you publish when ready. A draft you had open stays in the list.
- Queued emails keep the version they resolved. Publishing takes effect for the next send, never for one already accepted.
- Every sent email records the template and the exact version it used, so what a customer received stays correct in your records however often the template changes afterwards.
That last point is why templates belong in the API rather than in a file: the record of what was sent is kept next to the send. The templates reference lists every route, including duplicate and delete.
Sending by id or alias
A send names the template and its values, and carries no html or text of its own — the two cannot be combined. Values are strings or numbers of at most 2,000 characters; a declared key the request leaves out takes its fallback.
curl https://api.rasket.com/emails \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Idempotency-Key: welcome-user-8812" \ -H "Content-Type: application/json" \ -d '{ "to": ["ronald.williams@example.com"], "template": { "id": "welcome", "variables": { "name": "Ronald", "seats": 12 } } }'There is no from and no subject in the request because the template sets both. A request that sets them wins over the template; if neither does, the send is 422 missing_required_field. The idempotency key is derived from the user the welcome is for, so a retried sign-up cannot welcome them twice, and the emails reference has the rest of the request shape — attachments, tags, scheduling all work unchanged.
Only a published template can be sent
Naming a template that has no published version is refused rather than sent from whatever the draft happens to say. Publish first, then send; the versions list tells you which one the send will resolve.
Testing a template before publishing
Two routes cover the two questions you have before publishing: what will this render as, and what does it look like in an inbox.
Preview: render without sending
curl https://api.rasket.com/templates/welcome/preview \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Content-Type: application/json" \ -d '{ "variables": { "name": "Ada" }, "version": 2 }'
# 200# {# "object": "template_preview",# "version_number": 2,# "subject": "Welcome aboard, Ada",# "html": "<p>Hello Ada, your workspace has 1 seats.</p>",# "text": "Hello Ada, your workspace has 1 seats.",# "missing_variables": []# }The preview renders any version — the draft, by default — with the values you pass, the way a send would, and returns the subject, HTML and text plus missing_variables: every declared key that had neither a value nor a fallback, whose placeholder was left in place. Nothing is stored. Note the seats fallback of 1 printing “1 seats”, which is the kind of thing a preview exists to catch.
Test: send the draft to yourself
curl https://api.rasket.com/templates/welcome/test \ -H "Authorization: Bearer $RASKET_API_KEY" \ -H "User-Agent: acme-billing/1.0" \ -H "Idempotency-Key: welcome-test-v2" \ -H "Content-Type: application/json" \ -d '{ "to": ["ada@example.com"], "variables": { "name": "Ada", "seats": 3 } }'A test sends the current version — the open draft, if there is one — to up to five addresses with the subject prefixed [Test] . It counts as a send, and it takes an idempotency key so a retried call sends the test once. This is the route to wire into a review step: publish only after somebody has read the test in a real mail client.
Escaping and injection
Every value a send passes is data that came from somewhere, and often the somewhere is a customer: their name, their company, a field they typed into a form. Rendering that into an HTML body without escaping is the injection OWASP’s XSS prevention cheat sheet describes for untrusted data in an HTML context, and its rule is the one the renderer applies: encode the characters that mean something to an HTML parser before they land in the document.
Value sent: <script>alert(1)</script>
In html: <script>alert(1)</script>
In subject: <script>alert(1)</script>
In text: <script>alert(1)</script>
Value sent: {{seats}}
Everywhere: {{seats}} (inserted literally, never re-scanned)- Strings are HTML-escaped in
htmland inserted raw insubjectandtext. Numbers and booleans are written out as they are. - Objects and lists land as compact JSON, escaped in
html. They are for the rare template that prints structured data, not for building markup. - A substituted value is never re-scanned. A customer whose company is called
{{seats}}gets exactly that text; the placeholder is not expanded. A value cannot introduce a placeholder, so nothing a customer types can drive the template.
Why the subject is not escaped: it is not HTML. A subject is a header, and the text body is a text/plain part; only the text/html part is parsed as markup, per the media types RFC 2046 defines. Entity encoding & into & in a subject line would show the reader the entity, not the ampersand. Escape for the context the value lands in, and no other.
What escaping does not do is make a template a safe place for raw markup from outside. If a customer-supplied field genuinely has to carry HTML, that is a decision to make in your code, with a sanitiser, before the value reaches the send; the template will escape it otherwise, which is the right default. The glossary has the short definition, and the Node walkthrough shows the same send from the typed client.
Frequently asked questions
What happens if a send leaves a template variable out?
The variable takes the fallback_value declared on it. A declared variable with no value and no fallback is refused with a 422 rather than rendered with the placeholder showing, and a key the template never declared is refused too. The preview endpoint lists such keys in missing_variables.
Does a template support conditionals or loops?
No. The grammar is {{key}}, with optional spaces inside the braces, and nothing else: no logic, no filters and no nesting. Anything conditional belongs in the code that decides which template to send, and a list of items should be rendered into a string there or declared as a list variable that prints as JSON.
Are template variables escaped?
String values are HTML-escaped when they are inserted into the html body and left as written in the subject and the text body, because neither of those is HTML. Object and list values are inserted as compact JSON, escaped in html. A value is never re-scanned for placeholders, so a value of {{other}} lands literally.
How do I roll a template back to an earlier version?
Call POST /templates/{id}/versions/{n}/restore, which copies that version into a new draft, then publish the draft. Nothing changes for sends until the publish, emails already queued keep the version they resolved, and every email that was sent keeps its record of the version it used.
Can I send from a draft?
No. Only a published template can be sent, and naming a template that has no published version in a send is refused with a 422 rather than sent from whatever the draft happens to say. Use POST /templates/{id}/test to send the draft to up to five addresses of your own, with [Test] prefixed to the subject.
Does the send need a from and a subject when it uses a template?
Not when the template's published version sets them. A send that sets its own from or subject wins over the template, and a send where neither the request nor the template sets one is refused with 422 missing_required_field before anything is rendered.
Sources
- Cross Site Scripting Prevention Cheat Sheet — OWASP Cheat Sheet Series, read 2026-09-16
- RFC 2046: Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types — RFC Editor, read 2026-09-16
Related
- Templates — Write an email once, publish a version, and send it by ID or alias with typed variables. Every send records the exact version it used.
- Templates — Versioned email content with typed variables, addressed by ID or alias.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Template — A template is the content of an email kept apart from the code that sends it: a subject, a body, and named holes for the values that change.
- Send email from Node.js: API vs SMTP vs Nodemailer — Three ways to send email from Node.js — an HTTP API, SMTP and Nodemailer — what each one costs you, and a working send with retries and webhooks.