How it works
Write a variable in double braces
A variable is Handlebars, so it takes double braces:{{ 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).
Prefix every variable
Design Studio templates are strictly scoped. An unprefixed name does not fall back todata:
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:
snake case value.
Every other helper
Every other helper
- 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:
raw.html and the Templates API accept helper expressions the same way.
Combine helpers
Helpers nest, so one expression can feed another: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:
Helpers that only work on some channels
Three helpers are registered per channel rather than everywhere. Using one where it is not registered raisesMissing 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.
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 todata.orderId. Usedata.,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. tenantis a projection.tenant.id,tenant.name, andtenant.properties.*only.- There is no
contextnamespace.{{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
tofields are not referenceable.to.given_nameis not a namespace path. Pass the values you need throughdata, or store them on the recipient’sprofile, then referencedata.*orprofile.*.
FAQ
What happens if a variable is missing at send time?
What happens if a variable is missing at send time?
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.Why does my variable render empty?
Why does my variable render empty?
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.What is the difference between the data and profile namespaces?
What is the difference between the data and profile namespaces?
data is the payload you pass on the send. profile is the recipient’s stored or inline profile. Both need their prefix.Can I use a tenant's properties in a template?
Can I use a tenant's properties in a template?
{{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.Do Handlebars helpers work in Design Studio templates?
Do Handlebars helpers work in Design Studio templates?
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.How do I format a date or currency?
How do I format a date or currency?
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.How do I format a date like DD-Mmm-YYYY?
How do I format a date like DD-Mmm-YYYY?
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.I am migrating from SendWithUs. Do my helpers still work?
I am migrating from SendWithUs. Do my helpers still work?
swu_datetimeformat, swu_iso8601_to_time, and swu_timestamp_to_time are registered alongside the universal helpers, so existing templates keep working.Why can't I reference to.given_name in a template?
Why can't I reference to.given_name in a template?
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.