Skip to main content
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:
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).
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.
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.

Where the values come from

Each namespace is supplied by a different actor: 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.
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>.

What resolves, per trigger

For the first message of a journey: 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.

Linking to the conversation from an HTTP request

To put a link to the conversation in a Slack or webhook notification, use:
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.
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.
This namespace holds exactly five values — hoursSinceLastInbound, documentCount, lastInboundText, id, inboxUrl — and no others. Any other key under engagement.conversation. is refused at publish rather than sent as an empty string. Only documentCount binds in a message, an email or a dispatch; id and inboxUrl resolve in an HTTP request node, and hoursSinceLastInbound and lastInboundText in a Decision condition. 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).

Worked examples

Event-triggered journey: the event’s properties are the participant data. An event like loan_approved with a plan property binds as:
Segment- or manually-triggered journey: custom CDP person attributes are available as person.<key>:

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

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