> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Template variables and Handlebars helpers

> Use variables and Handlebars helpers to personalize a template with send data and profile fields.

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

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:

```handlebars theme={null}
Hi {{profile.first_name}}, your order {{data.order_id}} shipped.
```

In <Doc href="/docs/design/templates/design-studio">Design Studio</Doc>, 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.

<Note>
  **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}}`.
</Note>

### The data namespaces

Every variable is **namespaced**. Name the root you want:

| Root      | Comes from                                                                                                                   | Example                    |
| --------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `data`    | The `data` object on <Endpoint method="POST" path="/send" name="Send a message" href="/docs/api-reference/send/send-a-message" /> | `{{data.order.total}}`     |
| `profile` | The recipient's <Doc href="/docs/recipients/overview">profile</Doc> (send or stored)                                              | `{{profile.email}}`        |
| `tenant`  | The <Doc href="/docs/tenants/context">tenant</Doc> on the send                                                                    | `{{tenant.name}}`          |
| `brand`   | The <Doc href="/docs/design/brands">brand</Doc> applied to the send                                                               | `{{brand.colors.primary}}` |

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).

<Warning>
  **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.
</Warning>

### Prefix every variable

Design Studio templates are **strictly scoped**. An unprefixed name does not fall back to `data`:

| Write this               | Not this         |
| ------------------------ | ---------------- |
| `{{data.orderId}}`       | `{{orderId}}`    |
| `{{profile.first_name}}` | `{{first_name}}` |
| `{{tenant.name}}`        | `{{name}}`       |

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:

| Variable                  | Resolves to                                                                 |
| ------------------------- | --------------------------------------------------------------------------- |
| `{{tenant.id}}`           | The tenant's ID                                                             |
| `{{tenant.name}}`         | The tenant's name                                                           |
| `{{tenant.properties.*}}` | Any property you stored on the tenant, such as `{{tenant.properties.tier}}` |

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.

<Note>
  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.
</Note>

### Handlebars helpers

Helpers run on **every channel**, on Design Studio templates and older ones alike. Every example below runs against this send payload:

```json theme={null}
{
  "data": {
    "order": { "total": 42, "note": "Leave at the front door", "created_at": "2026-01-05T23:30:00Z" },
    "items": [{ "name": "widget", "price": 9.99 }, { "name": "gizmo", "price": 32.01 }],
    "subtotal": 40, "tax": 2, "tags": "new,vip"
  }
}
```

`profile.name` is not set, so `default` has something to fall back to.

| Helper            | Template                                                | Renders                               |
| ----------------- | ------------------------------------------------------- | ------------------------------------- |
| `default`         | `{{default profile.name "there"}}`                      | `there`                               |
| `capitalize`      | `{{capitalize "foo"}}`                                  | `Foo`                                 |
| `truncate`        | `{{truncate data.order.note 9 "..."}}`                  | `Leave at ...`                        |
| `concat`          | `{{concat "hello" " " "world"}}`                        | `hello world`                         |
| `trim`            | `{{trim "  a string  "}}`                               | `a string`                            |
| `split`           | `{{#each (split data.tags ",")}}`                       | `new`, then `vip`                     |
| `format`          | `{{format "$%.2f" data.order.total}}`                   | `$42.00`                              |
| `datetime-format` | `{{datetime-format data.order.created_at "%b %d, %Y"}}` | `Jan 05, 2026`                        |
| `add`             | `{{add data.subtotal data.tax}}`                        | `42`                                  |
| `t`               | `{{t "Salutation"}}`                                    | the string for the recipient's locale |

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:

```handlebars theme={null}
{{#replace-all "_" " "}}snake_case_value{{/replace-all}}
```

Renders `snake case value`.

<Accordion title="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.
</Accordion>

### 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 <Doc href="/docs/design/templates/design-studio#html">HTML block</Doc>, where the editor accepts raw Handlebars and the whole helper set resolves at send time:

```handlebars theme={null}
<p>Total: {{format "$%.2f" (add data.subtotal data.tax)}}</p>
<ul>
{{#each data.items}}
  <li>{{capitalize this.name}}: {{format "$%.2f" this.price}}</li>
{{/each}}
</ul>
```

Whole-email `raw.html` and the Templates API accept helper expressions the same way.

### Combine helpers

Helpers nest, so one expression can feed another:

```handlebars theme={null}
{{#each data.items}}
  {{capitalize this.name}}: {{format "$%.2f" this.price}}
{{/each}}

Total: {{format "$%.2f" (add data.subtotal data.tax)}}
```

Renders:

```text theme={null}
  Widget: $9.99
  Gizmo: $32.01

Total: $42.00
```

There is no `currency` helper. `format` is [sprintf](https://en.wikipedia.org/wiki/Printf), 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`:

```handlebars theme={null}
Hi   {{data.name}}   !
Hi   {{~data.name}}   !
Hi   {{data.name~}}   !
Hi   {{~data.name~}}   !
```

Those four lines render as:

```text theme={null}
Hi   Sarah   !
HiSarah   !
Hi   Sarah!
HiSarah!
```

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:

| Helper       | Works on               | Not on                            |
| ------------ | ---------------------- | --------------------------------- |
| `markdown`   | Email, Slack           | SMS, push, inbox, Microsoft Teams |
| `jsonnet`    | Slack, Microsoft Teams | everything else                   |
| `javascript` | Slack, Microsoft Teams | everything else                   |

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](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):

```html theme={null}
{{datetime-format data.order.created_at "%b %d, %Y %I:%M %p" "America/New_York"}}
<!-- Jan 05, 2026 06:30 PM -->

{{datetime-format data.order.created_at "%d-%b-%Y"}}
<!-- 05-Jan-2026 -->
```

| Token       | Meaning                                             | Example       |
| ----------- | --------------------------------------------------- | ------------- |
| `%A` / `%a` | Weekday, full / abbreviated                         | Monday / Mon  |
| `%B` / `%b` | Month, full / abbreviated                           | January / Jan |
| `%d`        | Day of month, zero-padded                           | 05            |
| `%m`        | Month number, zero-padded                           | 01            |
| `%Y` / `%y` | Year, with / without century                        | 2026 / 26     |
| `%H` / `%I` | Hour, 24- / 12-hour, zero-padded                    | 23 / 11       |
| `%M` / `%S` | Minute / second, zero-padded                        | 30 / 09       |
| `%p`        | AM or PM                                            | PM            |
| `%z`        | Timezone abbreviation (needs the timezone argument) | EST           |

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 <AppLink href="https://app.courier.com/logs">Logs</AppLink> names what it found.

<Warning>
  **It only catches single-brace `{order.total}`.**<br />
  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.
</Warning>

## 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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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`.
  </Accordion>
</AccordionGroup>
