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 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 each one is filled from a merged namespace, most specific winning:
- the recipient's contact attributes;
- the resolved brand's values;
- the per-send variables from the trigger.
So a slot does not have to be a variable your notification type declares. A brand key like {{ brand_logo }} or a contact attribute like {{ first_name }} works just as well.
<h1>Hi {{ 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.
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 repeat block and it renders once per row:
Template body<table>
<tbody>
{{#each lines}}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.qty }}</td>
<td>{{ item.amount }}</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr><td colspan="2">Total</td><td>{{ total }}</td></tr>
</tfoot>
</table>
Send the rows as a list of objects under the same name you used after #each:
POST /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 block, item. reads 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.
The rules are short:
- Rows are flat. A row is a set of name and value pairs. Blocks do not nest.
- 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 block to a template does not break callers who have not started sending the list.
- Every value is text, so format currency, dates and quantities the way you want them read.
Escaping: which values are safe
{{ item.anything }}) are escaped for you. A plain {{ slot }} is not. Its value is placed into the email exactly as given.That asymmetry is intentional, and useful in both directions.
A plain slot is left alone so you can pass markup through one: a block of pre-built HTML, a signature, a fragment your own system renders. If it were escaped, your customers would see the tags.
Row values are escaped because they are data. A product called Ben & Jerry's, or a note containing <, renders correctly instead of corrupting the surrounding table.
The practical guidance follows from that. Anything that came from one of your own users, such as a product name they typed, a note, or an address, belongs in a repeat block rather than a plain slot. If you interpolate untrusted text through a plain slot, you are placing unescaped HTML into an email you send.
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 repeat block shows a couple of placeholder rows, so you can see the repetition and the markup around it without inventing test data.
A template with a malformed block is rejected when you save it, with the reason: an unclosed {{#each}}, a nested one, or a {{ item.x }} sitting outside any block. Nothing that would print marker text into a real message can be stored.
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.