Variables
Every string a Flow node sends, requests or evaluates can contain {{...}} tokens. At run time the engine builds a template context for the step and replaces each token with the value at that path.
Hi {{contact.first_name}}, your order {{vars.api_result.body.order.id}} ships {{vars.eta}}.
Namespaces
| Namespace | What it holds | Examples |
|---|---|---|
contact | The contact the run belongs to | contact.first_name, contact.email, contact.phone, contact.language, contact.custom.loyalty_tier, contact.custom["plan-tier"], contact.tags, contact.lists, contact.methods[0].value |
message | The run's inbound message: the message that started the run, then the latest reply once the run resumes on one. For runs not started by a message (manual, API, schedules), the contact's most recent inbound message (see Message type and voice notes) | message.text, message.type, message.is_voice, message.channel_type, message.created_at, message.attachments[0].url |
conversation | The conversation the run is attached to (if any) | conversation.id, conversation.status, conversation.channel_type, conversation.messages[-1].text |
trigger | The data of the event that started the run (for runs started with POST /flows/{id}/runs, pass your own values in context and read them as vars.<key>) | trigger.amount_paid, trigger.order.items[0].sku, trigger.whatsapp_flow.answers.email (WhatsApp Flow completed trigger) |
vars | Flow variables: the context passed when the run was started through the API, plus values written by earlier nodes (Collect Input, KB query, Create Tracked Link, API Call, Custom Code, Send WhatsApp Flow) | vars.captured_email, vars.api_result.status, vars.order_total, vars.waflow.callback.slot |
flow | Run metadata | flow.id, flow.run_id, flow.name, flow.entry_at, flow.last_channel_used |
secrets | Stored flow secrets — API Call node only | secrets.STRIPE_KEY |
In the subject and body of saved email content sent by a Send or Send Email node, the email namespaces company.*, agent.* and system.* also resolve (see Email campaigns). There, agent.* is the agent assigned to the conversation, or empty when nobody is assigned. The content's own template variables (for example {{hero_title}}) take their values from the content, overridden by the node's template variables.
Legacy aliases such as {{contact.custom_fields.x}} keep working. The retired stop-link variables {{flow.stop_url}} and {{vars.stop_flow_url}} render as an empty string; to let contacts leave a flow, use the flow's exit settings instead (see Examples).
Message type and voice notes
{{message.type}} is the stored type of the message, in lower case: text, audio, image, video, document, file, location, sticker, contact, interactive, button, quick_reply, order, and a few channel-specific types. {{message.is_voice}} is true only for a recorded voice note (an audio message the contact recorded in the app, not an audio file they attached), otherwise false.
message.text, message.type and message.is_voice describe the message that started the run. When the run waits for the contact (Collect Input, Ask with Buttons) and resumes on a reply, they describe that reply instead. A voice note has no text, so message.text is empty for it; use a Branch on message.is_voice to route voice notes (see Condition).
Attachments
Each item of message.attachments is {id, filename, content_type, url}. The url is a private download link that stops working when the file is deleted (see attachment retention). A Collect Input node that requires a file also writes the matched file to vars["collect_input.<node_id>.attachment"] (the same shape plus kind), its URL to vars["collect_input.<node_id>.value"], and every attachment of the reply to vars["collect_input.<node_id>.attachments"]. These are flat keys containing dots, so use the quoted form, for example {{vars["collect_input.ask_photo.attachment"].url}}. If the node stores the file in a flow variable (flow.var.<key>), you can write {{vars.<key>.url}} instead.
Contact owner
{{contact.owner_id}}, {{contact.owner_name}} and {{contact.owner_email}} render the contact's owner. They are empty when the contact has no owner or the owner is no longer a member of the workspace.
Unconfirmed email / phone from Live Chat
A Live Chat visitor can type an email address or phone number into the pre-chat form that already belongs to another contact. SendSeven does not attach that value to the visitor's contact — it only suggests a merge for your team to review. So the visitor's contact has no email / phone of its own.
In message text (the body, caption, buttons, list rows, header, footer and template parameters of a Send, Send Template, Choice or Collect Input node, and the subject / body of an email sent by a Send or Send Email node) the engine therefore renders:
| Token | Renders as |
|---|---|
{{contact.email}} / {{contact.phone}} | The contact's own primary email / phone. Only when the contact has none, the value the visitor typed in Live Chat. |
{{contact.email_verified}} / {{contact.phone_verified}} | true when the shown value is the contact's own and not an unconfirmed Live Chat entry; false when it is a typed value nobody has confirmed yet, or there is no value at all. |
The marker is the same for every unconfirmed value, so a message such as We will reply to {{contact.email}} (confirmed: {{contact.email_verified}}) never tells the visitor whether their address belongs to someone else.
A typed value is display-only. It is never used to choose where a message goes (Send Email recipient, SMS / WhatsApp number, channel chain), to look up or match contacts, or to write to the contact (Tag / Update Contact and HubSpot nodes). It is also not part of the API Call, Custom Code, KB Query or Condition context, so it does not appear in webhook or API request payloads, and a Condition on contact.email still sees an empty value. After your team merges the two contacts from the merge suggestion, the merged contact's own email / phone is used.
Path grammar
identifier ( "." identifier | "[" integer "]" | "[" quoted-string "]" )*
- Dots walk into objects:
vars.order.customer.email. - Integer indices walk into arrays and may be negative:
vars.items[0],vars.items[-1]. - Quoted keys handle names with dashes or dots:
contact.custom["plan-tier"]. - A numeric segment against an object tries the string key (
vars.map[0]reads"0"). - Whitespace inside the braces is ignored:
{{ vars.x }}.
A path that does not exist renders as an empty string — never an error. Use a Condition node (or {{vars.api_result.ok}}) to guard against missing data.
How values become text
| Value | Renders as |
|---|---|
| string | as-is |
| number | 42, 3.5 |
| boolean | true / false |
null / missing | empty string |
| object / array | compact JSON ({"a":1}) |
Typed rendering in JSON bodies
The API Call node's body_json is a JSON template. A token placed outside quotes keeps the native type of the value it points to; a token inside quotes is interpolated as text.
{
"order_id": {{vars.api_result.body.order.id}},
"items": {{vars.api_result.body.order.items}},
"customer": "{{contact.first_name}} {{contact.last_name}}",
"paid": {{trigger.paid}}
}
renders (for a paid order 42 with two items) as
{"order_id": 42, "items": [{"sku": "X1", "qty": 2}, {"sku": "Y2", "qty": 1}], "customer": "Ada Lovelace", "paid": true}
The template must be valid JSON once every bare token is treated as a string placeholder — the validator rejects a body it cannot parse at save time, so a stray comma is caught in the editor, not at run time.
Secrets
Store credentials once under Flows → Secrets (or PUT /api/v1/flows/secrets/{NAME}) and reference them as {{secrets.NAME}} inside an API Call node's URL, headers, auth block or body. Names are upper-case identifiers (^[A-Z][A-Z0-9_]{0,63}$, lower-case input is upper-cased for you).
- Values are write-only: list and detail responses carry only a masked preview (
sk_…GH). - Publishing fails if a flow references a secret that does not exist; saving shows it as a warning.
- Secrets are resolved right before the request and are never exposed to Custom Code, Send nodes or webhooks.
- Anywhere a resolved value would appear in the step audit (request headers, URL, response body) it is replaced with
***. Standard credential headers (Authorization,Cookie,X-API-Key, ...) are masked regardless of their value.
Where variables come from
| Node | Writes |
|---|---|
| Collect Input | vars.<output_var_name> — the validated answer |
| KB query | vars.<output_var> — retrieved passages |
| Create Tracked Link | vars.<output_var_name> — the short URL |
| API Call | vars.<output_var> — {ok, status, headers, body, duration_ms, error} |
| Custom Code | one vars.<key> per key of the returned object, plus vars.<error_var> on failure |
| Run AI Assistant (agent target) | vars.<output_variable_prefix>.<key> (default prefix agent) — one value per declared output_schema property, written when the agent completes its task and the node leaves on done. See Run AI Assistant. |
| Send WhatsApp Flow | vars.waflow.<output_key>: every submitted answer by input name, plus status, completed_at, session_id, definition_id, reason. See Send WhatsApp Flow. waflow is reserved: it cannot be set when starting a run. |
Every one of these paths can also be tested directly in a Condition or Branch rule — as the rule's field (vars.api_result.body.count, with or without the {{ }} wrapper) or inside its value ("{{vars.threshold}}") — with an optional value_type cast (number, boolean, date, string). See Condition (Branch). secrets.* is the one namespace Conditions never resolve.
GET /api/v1/flows/variables?flow_id=... returns the same catalogue the editor's variable picker uses — see Triggers and the API.
Test context
The editor's Test request / Run script buttons and Flow test runs build the same context as a real run. Without a contact_id they substitute a sample contact ("Sam Sample") and a sample inbound message so {{contact.*}} and {{message.*}} tokens still render; vars.is_test is true in both cases.