Skip to main content
A SEND_MESSAGE node sends an approved WhatsApp template. 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).
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.

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

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