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

# Template variables

> How to bind a WhatsApp template's placeholders to live data in a journey.

A `SEND_MESSAGE` node in its default content mode sends an approved WhatsApp
template. (In `mode: "free_text"` it sends text you wrote, which binds by name
through `bindings` — the same grammar as `SEND_SMS` in the note below.)
Templates have positional placeholders (`{{1}}`, `{{2}}`, …). You fill each one by setting
**`templateBindings`** on the node: a map from a placeholder to the **path** of
the value it should carry:

```json theme={null}
{
  "templateBindings": {
    "1": "customer.name",
    "2": "engagement.workflowState.plan"
  }
}
```

For a value that is the same for every recipient (a discount code, a fixed
URL path), bind fixed text instead of a path by prefixing it with `literal:`:
`"3": "literal:BIENVENIDA20"`. In the visual builder, switch the placeholder's
row from **Variable** to **Text** and type it.

If a placeholder has no binding, or its path doesn't resolve, the message goes
out with that slot blank. So bind every placeholder, and use a path that
actually resolves. **Which paths resolve depends on how the journey is
triggered** (see the matrix below).

<Note>
  **`SEND_SMS` binds the same paths, but by name and with a stricter rule.** SMS
  bodies are free text, so placeholders are named rather than positional —
  `{{customer.name}}`, not `{{1}}` — and the map is `bindings`, not
  `templateBindings`. The visual builder writes each path as its own name; an
  alias works too, as long as the body uses the alias.

  Two differences matter. Publishing is **refused** if the body names a
  placeholder `bindings` does not supply, rather than sending a blank slot. And
  an unresolved placeholder is left visible at send time instead of blanked, so
  a binding to a path that does not resolve texts the customer the literal
  braces. `engagement.chatLink` is the one path to avoid here: it resolves only
  on the email step.
</Note>

<Note>
  **The catalog is the source of truth.** The variable catalog (`GET
      /api/v1/journeys/{journeyId}/message-variables`, the
  `journeys_message_variables` MCP tool, or the visual builder's picker) is
  authoritative for a specific journey. It already accounts for the journey's
  trigger, segment, and schemas. If this page and the catalog ever disagree,
  trust the catalog.
</Note>

## Where the values come from

Each namespace is supplied by a different actor:

| Namespace | Supplied by | Examples |
| - | - | - |
| `customer.*` | Boom: the contact's messaging identity. Always resolves. | `customer.name`, `customer.lastName`, `customer.phoneNumber`, `customer.email` |
| `person.*` | Your CDP: **live person attributes**, resolved when the message is sent | `person.plan`, `person.city` |
| `engagement.context.*` | You: participant data supplied at enrollment (CSV import, manual add, participants API) | `engagement.context.cohort` |
| `engagement.workflowState.*` | The trigger: **event/segment data**, frozen at enrollment | `engagement.workflowState.plan` |
| `engagement.extracted.*` | The conversation: extraction outputs | `engagement.extracted.churn_reason` |
| `engagement.nodeOutputs.<nodeId>.*` | An earlier HTTP request node's response | `…<nodeId>.status`, `…<nodeId>.body.result` |
| `engagement.documents` | Files the participant sent in the conversation (HTTP node body only) | `engagement.documents` |
| `engagement.conversation.*` | The live conversation: what the participant has sent — how long ago, how many documents, and the text of their last message — for Decision conditions, plus its id and inbox link (HTTP node only) | `engagement.conversation.lastInboundText`, `engagement.conversation.documentCount` |

Namespace notes:

* **`person.*`** re-resolves at send time from the CDP person the enrollment
  named, and is **offered for every trigger, including event-triggered
  journeys**. The phone or email lookup is only a fallback, used for contacts
  enrolled without a CDP person, and that fallback needs **exactly one** match:
  if several people share the phone or email, the value resolves blank. Full
  resolution order below.
* **`engagement.context.*`** is validated against the initiative's context
  schema and only exists when you enroll the participants yourself (CSV,
  manual add, API). Event- and segment-triggered enrollment never populates it.
* **`engagement.workflowState.*`** carries what the trigger delivered (the
  event's properties, the segment's output columns, or an optional payload on
  manual enrollment), frozen at enrollment. `_event` is a reserved key.
* **`engagement.extracted.*`** requires a published extraction schema and an
  upstream conversation block that has already run.

A custom attribute you set on a person (for example `plan`) is referenced as
**`person.plan`**, not `attributes.plan`.

<Warning>
  `attributes.<key>` is the syntax for **DECISION / CASE conditions**, a
  different part of a journey. It does **not** work in a message binding. In a
  `SEND_MESSAGE` template binding, a custom person attribute is always
  `person.<key>`.
</Warning>

## What resolves, per trigger

For the **first message** of a journey:

| Trigger | `customer.*` | `person.*` | `engagement.context.*` | `engagement.workflowState.*` |
| - | - | - | - | - |
| Manual / CSV / API add | ✅ | ✅ when a CDP person is linked | ✅ | Only if you pass a payload |
| Segment | ✅ | ✅ when a CDP person is linked | No | ✅ segment output columns |
| CDP event | ✅ | ✅ when a CDP person is linked | No | ✅ event properties |

`person.*` values are read **live at send time**, not frozen at enrollment, so
they behave the same for every trigger. Which CDP person they are read from is
decided in this order:

1. **The person the enrollment named.** If you enrolled the contact by
   `personExternalId`, which an event-triggered journey always does, that
   identity is used directly. It is authoritative: if that person has since been
   deleted or removed, `person.*` resolves blank rather than falling back.
2. **Otherwise, a lookup by phone or email.** For contacts enrolled without a
   CDP person (manual add, CSV import), Boom matches the contact back to a
   person. This needs **exactly one** match: zero matches, or several people
   sharing that phone number, resolve blank.

So duplicate CDP person rows only blank `person.*` on path 2. If your events
carry `personExternalId`, duplicates don't affect you.

`engagement.extracted.*`, `engagement.nodeOutputs.*`, and `engagement.documents`
are produced **during** the run, so they resolve later in the journey for every
trigger type, once the step that produces them has run.

* **`engagement.documents`** carries every file type the participant sent —
  PDFs, but also the photos and screenshots people send over WhatsApp. If your
  endpoint accepts only some of those, set `documentMimeTypes` on the HTTP
  request node (e.g. `["application/pdf"]`) and Boom withholds the rest instead
  of posting them for you to reject. Each entry is checked at publish against
  the types a customer's file can actually arrive with, so a misspelling is
  refused there rather than quietly withholding every document. Two things to
  handle either way: the array
  **can arrive empty** — a participant who sent only photos to a PDF-only node
  leaves nothing to send — and a file whose type Boom never recorded is
  withheld too, so accept an empty list rather than treating it as an error.

### Linking to the conversation from an HTTP request

To put a link to the conversation in a Slack or webhook notification, use:

```
{{engagement.conversation.inboxUrl}}   an absolute link to the conversation
{{engagement.conversation.id}}         just the conversation id
```

Both are offered in the HTTP request node's variable picker, under
**Conversation link**, and resolve **only in an HTTP request node**. They are refused when you publish
a message, an email, a dispatch, or a Decision condition that binds them — an id
is not something to show a customer, and you cannot know it when you are writing
the journey, so a Decision that checks it just fires every time.

They fill in once a conversation node in the journey has **closed**, which is
where the extracted values arrive too — so a notification placed after the AI
conversation gets the link, and one placed before it gets an empty string.
Validation warns you about that placement rather than letting it ship silently.
If several conversation nodes converge on the same request node, you get
whichever one actually ran; you never have to name it.

<Note>
  There is currently no way to get the link at the moment a customer **replies**,
  before the conversation ends — the value exists only once a conversation node
  closes. Tell us if you need it.
</Note>

<Note>
  This namespace is a closed set, and where each value works depends on the key:

  * **Decision conditions only** — `hoursSinceLastInbound`, `lastInboundText`,
    `lastInboundKind`, `newDocumentCount`.
  * **Decision conditions, and also messages, emails and dispatches** —
    `documentCount`.
  * **HTTP request nodes only** — `id`, `inboxUrl`.

  Any other key under `engagement.conversation.`, and any of these used outside
  the place it works, is refused at publish rather than sent as an empty string.
  To route on `lastInboundText`, use the **Matches keyword** operator rather than **Contains**: it is the same matcher your inbound trigger keywords use, so it folds accents and case and matches whole words (Contains is a raw substring test, so `precio` also fires on `precioso`).
</Note>

## Worked examples

**Event-triggered journey**: the event's properties are the participant data.
An event like `loan_approved` with a `plan` property binds as:

```json theme={null}
{
  "templateBindings": {
    "1": "customer.name",
    "2": "engagement.workflowState.plan"
  }
}
```

**Segment- or manually-triggered journey**: custom CDP person attributes are
available as `person.<key>`:

```json theme={null}
{
  "templateBindings": {
    "1": "customer.name",
    "2": "person.plan"
  }
}
```

## Formatting a value

A binding can append one formatting helper after a pipe. This is how you turn a
raw API value into something a customer can read, without changing the API:

```json theme={null}
{
  "templateBindings": {
    "1": "customer.name",
    "2": "engagement.nodeOutputs.fetch.body.total | currency:MXN",
    "3": "engagement.nodeOutputs.fetch.body.dueDate | date"
  }
}
```

| Helper | Example | Notes |
| - | - | - |
| `currency:CODE` | `total \| currency:MXN` | The code is required. Localized to your organization's language. |
| `date`, `date:long` | `dueDate \| date` | Accepts an ISO string, epoch milliseconds, or a date. Localized. |
| `sum`, `sum:field` | `lines \| sum:amount` | Totals a numeric array, or one field across an array of objects. |
| `count` | `buyers \| count` | Number of elements in an array. |
| `fixed:N` | `rate \| fixed:2` | Fixed decimal places, 0–20. |

Three things to know:

* **One helper per binding. Chaining is not supported** and is rejected rather
  than partly applied, so `total | sum | currency:MXN` is an error at publish, not
  a silent half-format.
* **`sum`, `count` and `fixed` are not localized**, on purpose: they stay a plain
  number so they remain usable in a condition comparison and valid inside a JSON
  request body. Only `currency` and `date` follow your organization's language.
* **A helper applied to a value that is missing is skipped**, so an absent field
  renders blank rather than `NaN` or `Invalid Date`.

Helpers also work in an HTTP Request node's URL and body — where a *formatting*
helper is usually the wrong choice, because `currency:MXN` sends
`"MX$1,234.50"` to an API that expects `1234.5`. Use **Test request** on the node
to see the resolved body exactly as it will be sent before you publish.

<Tip>
  Reaching for an array index like `body.buyers.0.account` is worth a second look:
  if the response can contain more than one element, position `0` is arbitrary and
  you may send the wrong record's data. Branch on `body.buyers | count` first and
  handle the "more than one" case explicitly.
</Tip>

## Discover the valid variables

Rather than guess, ask the API (or MCP) for the exact set a given journey can
bind: the same list the visual builder shows:

```http theme={null}
GET /api/v1/journeys/{journeyId}/message-variables
```

It returns the variables grouped by source, each with a ready-to-use `path`.
Copy a `path` straight into `templateBindings`. For event-triggered journeys
the response includes a `notes` field explaining which groups are unavailable
and why.

## Validation

When you save or validate a journey, a binding whose path can't resolve (for
example `attributes.plan` or `customer.attributes.plan`) is reported as an
error and blocks publishing, so a broken binding never ships silently. Fix it by
switching to a path from the catalog above (custom attributes → `person.<key>`).

The same applies to a formatting helper: an unknown helper name, or one that needs
an argument and has none (`total | currency` with no code), blocks publishing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.