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:
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._eventis a reserved key.engagement.extracted.*requires a published extraction schema and an upstream conversation block that has already run.
plan) is referenced as
person.plan, not attributes.plan.
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:
- 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. - 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.
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: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 likeloan_approved with a plan property binds 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:MXNis an error at publish, not a silent half-format. sum,countandfixedare not localized, on purpose: they stay a plain number so they remain usable in a condition comparison and valid inside a JSON request body. Onlycurrencyanddatefollow your organization’s language.- A helper applied to a value that is missing is skipped, so an absent field
renders blank rather than
NaNorInvalid Date.
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.
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: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 exampleattributes.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.