SendlySendly
Guides

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 &lt;script&gt;alert(1)&lt;/script&gt;</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 empty

A 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 at 0.
  • 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:

LimitValue
Nested blocks and snippet includes (one shared depth)10
Iterations rendered by a single {{#each}}1000
Characters of rendered output, snippets included262144
Snippet includes performed by one render100

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.

On this page