Templates
What a notification actually says, per channel, per brand and per language, and how to render a list whose length you only know at send time.
All documentation
Getting started
Overview Quickstart SDK packagesFrontend
React Vue Svelte React Native Web Components Headless & theming Real-timeBackend API
Authentication Templates Trigger & status Contacts & preferencesMore
Self-hosting MCP server ReferenceOne type, one template per channel
A notification type is the event your product emits, such as order_shipped or invoice_ready. A template is how one type reads on one channel. Create the type once, then give it a template for each channel you want it to reach.
Template existence is the switch: a type with an email template can be delivered by email, and a contact is only ever offered a channel that can actually render. Add a push template and push appears in that type's preference matrix. There is no separate list of supported channels to keep in step.
Slots
Anywhere in a subject, title or body, {{ a_name }} is a slot. At send time it is filled from one of three places, and the slot itself tells you which:
{{ contact.first_name }}: the recipient, meaning their attributes pluscontact.email,contact.language,contact.external_idandcontact.idfrom the contact record itself.{{ brand.logo }}: the resolved brand's values.{{ order_id }}: the per-send variables from the trigger, at the top level.
contact and brand are reserved: a trigger variable or a declared notification-type variable cannot use either name. Without that, a variable called contact would hide the recipient from every slot in every template that send touched.
A slot can also be filled from secret_variables, for a one-time code or a reset link. Those sit at the top level with the ordinary variables, so the template is written identically either way and moving a value between the two maps needs no template change.
<h1>Hi {{ contact.first_name }},</h1>
<p>Your order <strong>{{ order_id }}</strong> is on its way.</p>
<p>The {{ brand.name }} team</p>
A slot nobody fills renders as empty, not as an error. That is deliberate: adding a slot to a template does not break callers who have not started sending it yet, and a brand that has not set a value does not fail every send that uses it.
contact.email is always the address the message is going to. If you also store an attribute called email, the real address is what renders; a slot whose meaning depended on what happened to be stored would be a trap dressed as an obvious name.Repeating rows: invoice lines, order items
Some content is a list whose length you only know at send time. Wrap the repeating part in a loop and it renders once per row:
Template body<table>
<tbody>
{% for line in lines %}
<tr>
<td>{{ line.description }}</td>
<td>{{ line.qty }}</td>
<td>{{ line.amount }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="2">Total</td><td>{{ total }}</td></tr>
</tfoot>
</table>
Send the rows as a list of objects under the name you looped over:
TriggerPOST /v1/notifications
{
"notification_type_key": "invoice_ready",
"external_ids": ["crm-12345"],
"variables": {
"total": "£420.00",
"lines": [
{ "description": "Seat licence", "qty": "4", "amount": "£400.00" },
{ "description": "Support", "qty": "1", "amount": "£20.00" }
]
}
}
Inside the loop, the name you chose (line here) is the current row. Any other name still resolves the usual way, so a row can use {{ currency }} or {{ brand.name }} without you copying it into every row. forloop.index, forloop.first and forloop.last are available if you need to number rows or style the last one differently.
The rules are short:
- A row missing a field renders that field empty rather than failing the send.
- An empty list, or one you did not send at all, renders no rows. Adding a loop to a template does not break callers who have not started sending the list.
- Rows can nest. A row may itself carry a list, and a loop inside a loop reads it.
- Values can be any JSON: strings, numbers, booleans, nested objects.
Filters, conditions and defaults
Templates are Liquid, so beyond slots and loops you have conditions and a standard filter library. The pieces worth knowing first:
Template bodyHi {{ contact.first_name | default: "there" }},
{% if order.express %}Your order is on the express service.{% endif %}
You have {{ n }} {{ n | plural: "one=item|other=items" }} waiting.
defaultsupplies text when a value is missing or empty, so a greeting with no name still reads as a greeting.{% if %}shows a line only when a value says so.{% unless %}and{% case %}are there too.pluralpicks a word form from a count, using the language of the template variant. This one is ours; the rest are Liquid's.- Also useful:
upcase,truncate,date,round,join,size.
Plurals in any language
Plural forms are chosen by the CLDR rules every translation toolchain shares, using the language of the template variant. That matters beyond English: Polish has three forms and switches on the last two digits, so 22 takes the same form as 2. Arabic has six. Japanese has one. Write the forms your language actually has:
Template body, Polish variantMasz {{ n }} {{ n | plural: "one=produkt|few=produkty|many=produktów|other=produktu" }}.
The categories are CLDR's: zero, one, two, few, many, other. Only other is required, and it is what renders when no other category applies or when the value is not a number. For English, two forms is all you need.
{% include %} or {% render %}: a template is self-contained, with no partials to pull in. Loops are bounded, so a template cannot run away with itself during a send.Escaping: which values are safe
& in a notification is a bug.Escaping by default means a product called Ben & Jerry's, or a note containing <, renders correctly instead of corrupting the surrounding table. It also means text typed by one of your own users cannot inject markup into an email you send.
When you genuinely want to pass markup through a value, say so with | safe:
<div>{{ signature_html | safe }}</div>
Use it only for markup you control. A value you pass through | safe is placed into the email exactly as given.
Brand and language variants
One type can have several templates on the same channel, differing by brand and by language. At send time Elaan picks the closest match and falls back from the specific to the general:
brand + language → brand → default brand + language → default brand
Brand identity always wins over language: a customer's own branding with no Spanish variant sends that brand's language-less template rather than someone else's Spanish one. The language-less template on the default brand is the last resort, so keep one for every channel you send on. Without it, a send has nothing to render.
A contact's language comes from their preferred language. The brand comes from the trigger's branding_key, falling back to your account default if it is omitted or unknown.
Previewing
The console previews as you type, filling slots with your brand's real values and standing in the variable name where it has nothing to substitute. A loop shows a couple of placeholder rows, so you can see the repetition and the markup around it without inventing test data.
The console flags a syntax error as you type, with the line it is on, and the same check runs again when you save: a template that would not render cannot be stored. Nothing that would print tag text into a real message gets through.
Writing the email body
Email clients are not browsers. The starters in the console are the shape that survives them, using tables for layout, inline styles, a 600px container and web-safe fonts, and they are the fastest way to something that renders in Outlook as well as it does in your inbox. The editor has a visual mode for simple content and a code mode for full control. Table-based starters stay in code mode, because a visual editor cannot represent them without flattening the layout.