Skip to main content
Variables insert personalized values into a notification. Courier resolves them from the notification context, which is split into namespaces. Handlebars helpers add logic and formatting on top.

How it works

Write a variable in double braces

A variable is Handlebars, so it takes double braces:
In , typing {{ opens the variable picker and turns your selection into a variable chip. The chip is stored and rendered as {{path}}, so what you author and what sends are the same syntax.
Single braces are legacy. {order.total} still resolves on templates built in the older designer, and Courier keeps rendering them so existing templates keep working. Everything new uses {{ }}, including everything in Design Studio. Prefer {{data.order.total}}.

The data namespaces

Every variable is namespaced. Name the root you want: Courier also populates built-in values, including {{urls.unsubscribe}}, {{urls.preferences}}, {{recipient}}, {{courier.environment}}, and {{datetime.year}}. Variable names must be camelCase or snake_case. Dashes break resolution ({{data.first_name}} works, {{data.first-name}} does not).
There is no context. namespace. Nothing in the render path binds one, so {{context.tenant_id}} resolves to an empty string. Read the tenant through {{tenant.id}} instead.

Prefix every variable

Design Studio templates are strictly scoped. An unprefixed name does not fall back to data: A bare name is worse than empty: helper names are reserved, so {{path}} invokes the path helper rather than reading data.path. Design Studio marks any variable that does not start with profile., data., or tenant. (or $.item. inside a List loop) as invalid, with the message "<name>" must start with profile., data., tenant., or $.item. (in loops). Templates created in the older designer, and inline content sends, are default-scoped: data is spread onto the root there, so a bare {{orderId}} does resolve. Prefixing works in both modes, so prefix everywhere.

What tenant exposes

tenant is a narrow projection of the tenant on the send, not the whole account record: Nothing else on the tenant is reachable. default_preferences, notification_map, parent_tenant_id, brand_id, and user_profile stay out of template content by design. Design Studio autocompletes tenant.id, tenant.name, and the property paths it finds across your workspace’s tenants, so the picker matches what sends.
On a default-scoped (older designer) template, a data.tenant field you send yourself still wins over the namespace. Reach the real tenant with {{path "tenant.name"}} there.

Handlebars helpers

Helpers run on every channel, on Design Studio templates and older ones alike. Every example below runs against this send payload:
profile.name is not set, so default has something to fall back to. Note the data. prefix on every payload field. Under strict scope a bare {{order.total}} resolves to nothing, and a bare {{format}} calls the helper with no arguments. replace-all is a block helper, so it wraps the text it changes:
Renders snake case value.
  • Logic: {{#if}}, {{#each}}, {{#with}}, and, or, not, contains, condition, conditional, filter
  • Values: set, var, path, params, inc, range, json-parse, parse-string
  • Text: line-break, text-direction, trim-left, trim-right
  • Math: subtract (sub), multiply (product), divide, mod, abs, ceil, floor, round
condition takes the operator as a string: {{#if (condition data.plan "==" "pro")}}. The editor autocompletes all of them.

Where to write a helper expression

Design Studio’s rich-text blocks are built for variables, not expressions: typing {{ opens the variable picker, and a helper call typed into a chip fails the chip’s name validation and turns red. Put helper expressions in an , where the editor accepts raw Handlebars and the whole helper set resolves at send time:
Whole-email raw.html and the Templates API accept helper expressions the same way.

Combine helpers

Helpers nest, so one expression can feed another:
Renders:
There is no currency helper. format is sprintf, so write the currency symbol into the format string.

Control whitespace

A ~ inside the braces strips every space and newline touching that side of the expression. Whitespace is invisible in a delivered message, so the effect only reads side by side. With data.name set to Sarah:
Those four lines render as:
Use it to keep an SMS or a plain-text email from picking up stray blank lines.

Helpers that only work on some channels

Three helpers are registered per channel rather than everywhere. Using one where it is not registered raises Missing helper: "<name>" at send time: These three are also absent from the helper set that resolves Elemental block fields, so a Design Studio text or heading block cannot call them on any channel. Everything in the list above this table is channel-independent. If a universal helper raises Missing helper, that is a bug worth reporting, not a limit.

The preview does not resolve helpers

The designer’s preview substitutes variables, but it does not run helpers. A {{truncate}} or {{datetime-format}} can look wrong in the preview and render correctly in the delivered message. Send a test message to check helper output. The delivered message is the only place helper results are real.

Format dates and times

datetime-format turns an ISO 8601 timestamp or a milliseconds-since-epoch value into a formatted date using strftime-style tokens. Pass the value, a format string, and optionally an IANA timezone:
Include the timezone argument to convert the value into that zone. %z then renders its abbreviation. datetime-format throws if a numeric input is not an integer count of milliseconds.

When a variable is missing

Rendering is non-strict by default, and a send never fails on a missing field. A missing {{ }} variable renders as an empty string. A missing legacy single-brace variable renders as the literal placeholder ({order.total}). For an inline fallback, use the default helper: {{default profile.first_name "there"}}. The template’s Advanced settings carry a Throw on variable not found checkbox, still marked Beta. After rendering, Courier scans the output for a leftover placeholder. Finding one raises VariableNotFound, so that provider’s send fails and names what it found.
It only catches single-brace {order.total}.
The check looks for a single-brace placeholder still sitting in the rendered output. A {{ }} variable that resolved to nothing rendered as an empty string, so there is nothing left to find. Every variable in a Design Studio template is {{ }}, so turning the setting on there changes nothing. Use default for a fallback, and a test send to catch a wrong path.

Limits & behavior

  • Variables are double-braced. {{data.orderId}}. Single braces are a legacy syntax that Courier still renders.
  • Prefix everything. Design Studio templates are strictly scoped, so {{orderId}} does not fall back to data.orderId. Use data., profile., tenant., or $.item. in a loop.
  • Helper names are reserved. A bare {{path}} or {{format}} calls the helper, not your field. Prefix the field.
  • Helpers live in HTML blocks in the designer. Rich-text blocks convert {{ into a variable chip. Helper expressions belong in an HTML block, raw.html, or the API.
  • tenant is a projection. tenant.id, tenant.name, and tenant.properties.* only.
  • There is no context namespace. {{context.*}} renders empty.
  • Missing variables are silent. {{ }} renders empty, and legacy { } renders the literal {path}. Throw on variable not found fails a send only on a leftover single-brace placeholder.
  • Names are camelCase or snake_case. Dashes and other special characters break variable resolution.
  • Recipient to fields are not referenceable. to.given_name is not a namespace path. Pass the values you need through data, or store them on the recipient’s profile, then reference data.* or profile.*.

FAQ

A {{ }} variable renders as an empty string and the send succeeds. Use {{default data.value "fallback"}} to supply a fallback. Throw on variable not found in the template’s Advanced settings only fires on a leftover single-brace {path}, so it has no effect on a template authored in Design Studio.
Almost always a missing prefix. Design Studio templates are strictly scoped, so {{orderId}} does not read data.orderId. Write {{data.orderId}}. Check for a red chip in the editor: it means the name does not start with profile., data., or tenant.. A bare name that matches a helper ({{path}}, {{format}}, {{default}}) calls the helper instead of reading your field.
data is the payload you pass on the send. profile is the recipient’s stored or inline profile. Both need their prefix.
{{tenant.id}}, {{tenant.name}}, and {{tenant.properties.<path>}} resolve from the tenant on the send, and Design Studio autocompletes the property paths it finds across your tenants. Other fields on the tenant record are not reachable from template content.
The full universal helper set resolves at send time on every channel. The designer’s rich-text blocks are the one constraint: typing {{ there opens the variable picker, so write helper expressions in an HTML block, in whole-email raw.html, or through the Templates API. Remember the data. prefix on payload fields.
Use datetime-format for dates. For currency, format takes a sprintf pattern, so {{format "$%.2f" data.order.total}} renders $42.00. There is no currency helper.
Use datetime-format with strftime tokens: {{datetime-format data.order.created_at "%d-%b-%Y"}} renders 05-Jan-2026. Add a time and AM/PM with %I:%M %p, and pass an IANA timezone as the third argument to convert the value into that zone.
swu_datetimeformat, swu_iso8601_to_time, and swu_timestamp_to_time are registered alongside the universal helpers, so existing templates keep working.
The recipient to object is not a variable namespace. to.given_name does not resolve, and the editor marks it invalid: "to.given_name" must start with profile., data., tenant., or $.item. (in loops). Reference recipient values through profile.given_name when they are stored on the profile, or pass them on the send’s data and use data.given_name.