Template Syntax
Variables, conditionals, loops and reusable snippets — and the one rule that makes all of them safe to point at a stranger's data
Every email Sendly sends is rendered from a template: a subject and a body with placeholders in them. The same renderer runs for transactional sends, campaigns and workflow steps, and the same one runs the preview in the editor, so what you see there is what the recipient gets.
The one rule
Values are escaped. Markup is not.
The template body is yours: you wrote it, and Sendly renders your <table> as a table. A value —
anything that arrives from a contact record, a custom field, or the data you pass to the send
API — is somebody else's text, and it is HTML-escaped before it lands in the body.
Template: <p>Hello {{firstName}}</p>
Value: firstName = <script>alert(1)</script>
Rendered: <p>Hello <script>alert(1)</script></p>That holds everywhere: inside a conditional, inside a loop, and inside a snippet pulled into a
loop. There is no "raw" or "unescaped" form of a placeholder, and no helper that turns escaping
off. {{{firstName}}} is not raw output — it is read as a variable named {firstName, which
never exists, so it renders as nothing.
Subjects are not HTML, so they are treated differently: values in a subject have line breaks and
control characters stripped (which is what stops a value from injecting a Bcc: header), and they
are not entity-encoded.
Variables
{{email}} A top-level field
{{customFields.plan}} A nested path
{{firstName ?? there}} A default when the value is missing or emptyA variable that resolves to nothing renders as an empty string. The ?? default text is yours,
not a value, so it is not escaped — write markup in it if you want to.
An array renders as <li> items in a body and as a comma-separated list in a subject. Use
{{#each}} below when you want control over the markup.
Conditionals
{{#if plan}}You are on the {{plan}} plan.{{else}}Pick a plan when you are ready.{{/if}}
{{#unless unsubscribed}}<a href="{{manageUrl}}">Manage your preferences</a>{{/unless}}Empty means absent: an empty string, an empty list, an empty object, 0 and false all take the
{{else}} branch. The branch that is not taken renders nothing at all.
Loops
{{#each items}}
<tr><td>{{@index}}</td><td>{{name}}</td><td>{{price}}</td></tr>
{{else}}
<tr><td>Your order is empty.</td></tr>
{{/each}}Inside the loop:
{{this}}is the current item, for a list of plain strings or numbers.{{@index}}is the position, starting at0.- A field name (
{{name}}) reads the field of the current item, and falls back to the outer contact record when the item has no such field — so{{firstName}}still works inside a loop over line items. {{this.field}}reads the item's field only, when you want to be explicit.
{{else}} inside {{#each}} renders when the list is empty or missing.
Snippets
A snippet is a fragment of template markup stored on your project and reused by name. Create one
from the Insert Snippet control in the template editor, or through the API
(POST /api/snippets). Include it with {{> name}}:
<p>Your order is on its way.</p>
{{> signature}}
{{> legal_footer}}Variables inside a snippet resolve against the same contact record the surrounding template uses, and they are escaped the same way. A snippet may include another snippet, and may contain conditionals and loops.
The name in {{> name}} is always a literal identifier — it must start with a letter and contain
only letters, digits, hyphen and underscore. There is no way to choose a snippet from a value.
{{> {{whichever}}}} does not select the snippet a contact field names; it is rejected. That is
deliberate: a template author picks what gets included, never the data.
A snippet that does not exist renders as an empty string, exactly like a variable that does not exist. Deleting a snippet therefore leaves templates that include it still sending — with a gap where it was.
Comments
{{! This note never reaches the recipient. }}Limits
A template and the snippets it pulls in are rendered on every send, so a render is bounded. These are the exact limits:
| Limit | Value |
|---|---|
| Nested blocks and snippet includes (one shared depth) | 10 |
Iterations rendered by a single {{#each}} | 1000 |
| Characters of rendered output, snippets included | 262144 |
| Snippet includes performed by one render | 100 |
Exceeding a limit fails the render rather than sending a half-built message; so does an unclosed
{{#if}} or {{#each}}, or an unknown block helper. The editor preview reports which one, so you
can see the mistake while you are still writing.
Two snippets that include each other are stopped by the depth limit rather than looping forever.