Skip to main content

Node Reference

Every node type accepts a config object that's specific to its purpose. Common fields like id, type, and position (for the canvas) are stripped before execution.

Common fields​

Every node — regardless of type — also carries:

FieldTypeNotes
display_namestringHuman-readable label shown on the canvas and in the jump-to-node edge picker. Case-insensitive unique within a flow; the validator rejects publishes with duplicate display names. Defaults to a type-derived placeholder ("Send 1", "Branch 2", etc.) until the user edits it.

The display name is what you pick from when wiring jump-to-node edges (a non-linear routing edge that targets a named node anywhere in the flow), so giving nodes meaningful names makes complex flows much easier to maintain.

Variable substitution. Every string field that says "variable substitution supported" accepts {{...}} tokens with nested paths — {{contact.first_name}}, {{vars.order.items[0].sku}}, {{message.text}}, {{trigger.amount_paid}}, {{secrets.API_KEY}} (API Call only). The full grammar, the namespaces and the typed-JSON rules live in Variables.

note

"Copy node ID" was removed from the node context menu in May 2026 — referencing nodes by display_name is now the supported pattern for both UI wiring and operator readability.

Send​

Sends a message to the contact across one or more channels with channel-specific content.

FieldTypeNotes
channel_chainstring[]Ordered list of channels to try. ["whatsapp", "telegram", "email"] means try WhatsApp first; if the contact has no WhatsApp method or it fails, fall back to Telegram, then email.
channel_contentobjectPer-channel payloads. Each key is a channel type; each value is the message body for that channel.
body_textstringLegacy / fallback. Used if channel_content is empty.
subjectstringEmail only. Required for email sends.
email_mailbox_iduuidEmail only. Picks the From mailbox.
template_iduuidWhatsApp only. References an APPROVED WhatsApp template.
template_variablesobjectWhatsApp template variable values, including variable_fallbacks for nullable fields.
header_image_urlstringWhatsApp template header image. Pre-uploaded to Meta as a 30-day media ID.
include_signaturebooleanEmail only. Default true. Appends the user's HTML signature.
buttonsobject[]Tappable buttons under the message — see below.

Buttons. Each entry is {"id", "caption"} for a classic postback button (the tap comes back and the run resumes along the edge wired as branch="btn_<id>"), or {"caption", "type": "url", "url"} for a link button that opens an https:// page. type defaults to postback, so existing flows are unaffected. Link buttons never receive a tap event: they need no id (one is generated), take no outgoing edge, and a message whose buttons are all links does not wait for a reply. Link buttons render natively on Telegram, Facebook Messenger, Instagram and RCS; on every other channel the link is appended to the message text as a Caption: url line so it always reaches the contact.

Variable substitution. The body, subject, and template parameters all support {{contact.first_name}}, {{contact.email}}, {{vars.<flow_var>}}, and {{flow.var.<name>}}. Unresolved variables fall back to per-parameter variable_fallbacks (WhatsApp templates) or empty strings.

Conversation behavior. By default, flow sends do NOT open a conversation in the inbox. The first inbound reply attaches the prior 30 days of orphan flow messages to a new conversation (configurable per flow via inbound_behavior).

Delay​

Pauses the run for a fixed amount of time.

FieldTypeNotes
durationintegerWait duration, in the chosen unit.
unit"seconds" | "minutes" | "hours" | "days"Time unit. The UI surfaces all four; the engine normalizes to seconds internally.
duration_secondsintegerRead-only effective value, computed from duration × unit. Max 14 days (1209600 seconds).

Standalone Delay node cap. A standalone Delay node is capped at 30 seconds. For longer waits, use a Condition node with wait_max_seconds (which is purpose-built for long-running waits and uses the tiered recheck cadence) — a standalone Delay tying up engine resources for hours or days would push backpressure onto the scheduler.

Inter-message delay inside Send. When a Send node carries 2 or more messages, the per-message delay is capped at 10 seconds (_SEND_MESSAGE_DELAY_MAX_MULTI = 10 on the server) to avoid tripping provider throttling on WhatsApp / Telegram bursts. The validator enforces this at save time; values above 10 s are rejected with an explanatory error.

Internally, the engine creates a scheduled_sends-style row with next_run_at = now + duration. The scheduler tick republishes a resume task when the deadline passes.

Collect Input​

Sends a prompt and waits for the contact's next inbound message. The reply is stored in a flow variable.

FieldTypeNotes
promptstringMessage text shown to the contact. Same variable substitution as Send.
output_var_namestringFlow variable to write the reply into. Reference downstream as {{vars.<name>}}.
timeout_secondsintegerOptional. If no reply arrives within this window, the engine takes the timeout branch (configured via an outgoing edge with branch: "timeout").
channel_chainstring[]Same as Send — chooses where to send the prompt.

While a Collect Input node is waiting, replies on the same channel are not routed to a conversation in the inbox — they go to the flow. This makes flows usable as bot builders. Replies received during a Collect Input wait are still persisted as messages rows.

Storing the answer. Beyond writing the reply into a flow variable, the "store the answer" step can also write it back onto the contact. Two kinds of store target are available:

  • Contact method (recommended for email / phone). Targets a contact method of type email or phone. If the contact has no method of that type, it is created; if one already exists, it is updated in place. An optional "make primary" toggle promotes the value to the contact's primary email/phone of that type. This is the recommended way to capture an email or phone number, because it feeds the contact-method model the rest of the platform reads from.
  • Contact field (legacy). The older targets that write a denormalized contact field (email, phone, custom fields, …) remain for backward compatibility.

Timeout target. The timeout branch is wired once on the canvas (the outgoing edge with branch: "timeout"). The old duplicate timeout picker inside the node config was removed — set it on the branch, not in the config.

Requiring a file​

Set the validation type to file to make the node wait for an image, video, audio file or document instead of text:

"validation": {
"type": "file",
"accepted_kinds": ["image", "document"]
}
FieldNotes
validation.type"file"
validation.accepted_kindsAny of image, video, audio, document, any. Missing, empty or null means ["any"]. Other validation fields (regex, min_length, enum_values, …) are ignored for file.

How a reply is checked. The reply is valid when it carries at least one attachment of an accepted kind. The engine takes the first attachment (in message order) whose kind is accepted. Caption text sent with the file does not affect the result. A reply without an attachment, or with only attachments of other kinds, counts as an invalid attempt and follows the node's usual retry path (validation_error_message, max_attempts, timeout and fallback). A tap on one of the node's own buttons still skips validation, so you can add a "Skip" button.

Kind matching. The attachment's MIME type is checked first:

KindMIME typesFile extensions (fallback)
imageimage/* (WhatsApp stickers count as images)jpg, jpeg, png, gif, webp, heic, heif, bmp, tiff, svg
videovideo/*mp4, mov, m4v, 3gp, webm, avi, mkv
audioaudio/* (including voice notes)mp3, m4a, aac, ogg, oga, opus, wav, amr, flac
documentapplication/pdf, Microsoft Office, OpenDocument, application/rtf, text/plain, text/csvpdf, doc, docx, xls, xlsx, ppt, pptx, odt, ods, odp, rtf, txt, csv

The file extension is only used when the MIME type is missing, empty or application/octet-stream. An attachment that matches no kind is only accepted by any. Locations, contact cards and reactions are not attachments.

Values written on success. The node writes these flow variables (the keys contain dots, so read them with a quoted key, for example {{vars["collect_input.ask_photo.value"]}} or {{vars["collect_input.ask_photo.attachment"].url}}. For simpler references, set a flow.var.<key> store target (below) and use {{vars.<key>.url}}):

Variable keyValue
collect_input.<node_id>.valueURL of the matched attachment (string)
collect_input.<node_id>.attachmentThe matched attachment: {id, url, filename, content_type, kind}
collect_input.<node_id>.attachmentsEvery attachment of the reply, same shape (kind is null when no kind matched)
last_inboundThe caption of the reply (may be an empty string)

The URL is a private, unguessable download link. It is not a public link to share, and it stops working when the file is deleted, for example by attachment retention. Copy the file elsewhere if you need to keep it.

Store target (target_field). For file, only these targets are allowed:

TargetWhat is stored
emptyNothing extra; the values above are still written.
flow.var.<key>The matched attachment object {id, url, filename, content_type, kind}. Use {{vars.<key>.url}}, {{vars.<key>.filename}}.
contact.custom.<key>The matched attachment URL (string). Use a custom field of type text or url.

Built-in contact fields (contact.<column>) and contact-method targets are rejected for file.

Publish-time checks.

CodeLevelWhen
collect_input_file_kind_invaliderroraccepted_kinds contains an unknown value
collect_input_file_target_invaliderrortarget_field is a built-in contact field or a contact method
collect_input_file_kinds_any_redundantwarningany is listed together with other kinds (treated as ["any"])
collect_input_file_channel_unsupportedwarningthe node's channel chain (or the flow's default chain) includes a channel that cannot complete a file step: SMS and Email

Channels. SMS cannot carry files from contacts, and an email reply never resumes a waiting Collect Input node, so a file step cannot be completed on either channel. On Live Chat, visitor attachments can be switched off per widget. If they are off, visitors cannot complete a file step on that widget.

Condition (Branch)​

Branches the flow based on a typed rule set. Optionally waits up to wait_max_seconds for the condition to become true (e.g., wait for a click, then take the YES branch).

FieldTypeNotes
rulesRule[]Each rule has field, operator, value and an optional value_type. See below.
rules_op"all" | "any"How rules combine. Default all.
wait_max_secondsintegerOptional. 0 (default) = evaluate once. >0 = re-evaluate periodically; if true, take YES; if still false at the deadline, take NO.

Edges from a Condition node carry branch: "yes" or branch: "no". Both must be present for the flow to publish.

The Branch node (type: "branch") is the multi-way sibling: config.cases[] is a list of {label, rules, rules_op} and the first matching case wins (branch: "case:<index>", else branch: "else"). Every rule in a Branch case follows exactly the same grammar and casting semantics as a Condition rule — they share one evaluator.

Rule shape​

{
"field": "vars.api_result.body.count",
"operator": "gt",
"value": "{{vars.threshold}}",
"value_type": "number"
}

field (left-hand side). Either one of the curated keys the editor offers (contact.first_name, contact.custom.<key>, contact.tag, flow.last_channel_used, flow.last_button_clicked, flow.last_inbound, flow.entry_at, subscription, …) or any nested path in the namespaces vars, contact, message, conversation, trigger, flow, written in the variable grammar: dots, [0] / [-1] list indexes and ["key with-dashes"] map keys. A {{ … }} wrapper and surrounding whitespace are accepted and stripped, so you can paste a token from the variable picker as-is. The legacy alias flow.var.<name> still resolves (it maps to vars.<name>).

vars.api_result.body.count          vars.api_result.body.items[0].sku
trigger.order.total contact.custom["plan-tier"]
message.text conversation.status

To route by what the contact sent, branch on message.type or message.is_voice (see Message type and voice notes):

{ "field": "message.is_voice", "operator": "is_true", "value_type": "boolean" }
{ "field": "message.type", "operator": "one_of", "value": ["image", "document"] }

secrets.* is not a valid namespace here — secrets never reach a Condition, neither as field nor inside value. vars keys starting with __ are engine-internal and resolve as missing. A path that does not resolve is treated as "missing": exists / is_not_empty are false, not_exists / is_empty / not_one_of are true, every other operator is false.

value (right-hand side). A literal, or a string containing {{ … }} templates rendered with the same context as an API Call node. When the whole value is exactly one token ("{{vars.threshold}}") the resolved value keeps its native type (number, boolean, list, object); mixed text renders to a string. Missing tokens render empty.

value_type (optional). Controls how both sides are cast before comparing. Omit it (or "auto") to keep the historical behaviour.

value_typeCastsOperatorsCast failure
auto (default)Type-sniffing: numbers-as-strings compare numerically for lt/lte/gt/gte; ISO dates for before/after/on; is_true/is_false accept true/yes/1/on; everything else is compared as trimmed, case-insensitive text.allfalls back to text comparison
stringBoth sides → text (lists / objects become compact JSON).eq neq contains not_contains starts_with ends_with one_of not_one_of contains_any is_empty is_not_empty lt lte gt gte exists not_exists (lt…gte are lexicographic)—
numberBoth sides → float ("42", " 3.5 " accepted; booleans are not numbers).eq neq lt lte gt gte is_empty is_not_empty exists not_existsrule is false
booleantrue/yes/1/on → true, false/no/0/off/"" → false.is_true is_false eq neq exists not_existsrule is false
dateISO date / datetime → date. within_last_days / more_than_days_ago take a day count on the right.eq neq before after on lt lte gt gte within_last_days more_than_days_ago is_empty is_not_empty exists not_existsrule is false

"10" gt "9" is therefore true under number and under auto, but false under string.

Lists and objects on the left (auto mode). eq / neq compare against the JSON-parsed right side ('["red","blue"]', '{"paid": true}') or, if it does not parse, against the compact-JSON text. On a list, contains / not_contains / one_of / not_one_of / contains_any test element membership (stringified, case-insensitive) — so vars.api_result.body.tags contains "blue" and contact.tags one_of ["vip", "beta"] do what you expect. is_empty / exists treat [], {}, null and "" as empty. Other string operators run on the compact-JSON text.

Multi-select custom fields. A rule on a multi-select (or collection) custom field (contact.custom.<key>) sees the field as a list of the selected options. contains / not_contains / contains_any / one_of therefore match whole options, case-insensitive: contains "Pro" does not match a contact who selected only Pro Plus. The stored value of an option is its text, so compare against that text. A cleared field counts as empty.

Contact-method conditions​

"has contact method" for a channel. The editor offers a has contact method condition on the contact: it checks whether the contact has a contact method of a given type — WhatsApp, Telegram, Messenger, Instagram, SMS, RCS, email, or live chat — and its negation, does not have. This is the reliable way to branch on "does this contact have an email address / a WhatsApp number?" before a Send that targets that channel. Under the hood it is the has_contact_method_for_channel operator on the curated contact.has_method field, with the channel type as value.

{ "field": "contact.has_method", "operator": "has_contact_method_for_channel", "value": "email" }

Email / phone read the primary contact method. Condition checks on a contact's email (contact.email) and phone (contact.phone) now read the contact's primary email/phone, resolved from their contact methods — not the old legacy denormalized columns. A contact with no email/phone resolves to an empty string (""), not null, so you branch on absence with is_empty (or eq "") and on presence with is_not_empty. Because the value is always a (possibly empty) string, exists fires only when a non-empty value is present — an empty string counts as missing.

Contact language conditions​

Six dedicated operators branch on the contact's language. They only work on the field contact.language, and they use the same rules as the Custom Inbox language rule:

OperatorvalueTrue when
language_isone codeThe contact's language is this language
language_inlist of codesThe contact's language is one of them
language_notone codeAny other language, or no language (see below)
language_not_inlist of codesNone of them, or no language (see below)
language_identifiednoneA language is known for the contact
language_unidentifiednoneNo language is known (or the run has no contact)
  • Any listed language counts. The contact's languages are its preferred language plus every code in its languages list: language_is / language_in are true when any of them matches, language_not / language_not_in when none does, language_identified when at least one is known. Codes are compared as whole entries (tr never matches str).
  • Codes are ISO codes such as ar, de, pt-BR (max 10 characters, max 100 per rule). Case and _ / - do not matter. A code without a region also matches its regional variants (ar matches ar-SA); pt-BR matches only Brazilian Portuguese.
  • include_unidentified: true (optional, default false) adds "No language identified" to the value set: language_in ["ar"] with include_unidentified: true is true for Arabic speakers and contacts with no language. language_in / language_not_in may then have an empty list.
  • language_not / language_not_in are the exact opposite of language_is / language_in, so they are also true for contacts with no language, unless include_unidentified is true.
  • Codes are literal; a {{ … }} template is accepted and checked at run time (invalid codes are then ignored).
  • The older rule {"field": "contact.language", "operator": "eq", "value": "de"} keeps its exact text comparison (no region matching).
{ "field": "contact.language", "operator": "language_in", "value": ["ar"], "include_unidentified": true }

Publish-time validation​

  • field must be a curated key or a parseable path in a known namespace (secrets.x, foo.bar and malformed paths are rejected).
  • value_type must be one of auto, string, number, boolean, date; the operator must be allowed for that type (see table).
  • A literal value (no {{) must parse for the chosen type: a number, true/false, an ISO date, or a day count for the *_days* operators. Templated values are checked at run time instead.
  • language_* operators require the field contact.language, valid language codes, a list for language_in / language_not_in (non-empty unless include_unidentified is true) and a boolean include_unidentified.

Examples​

{ "field": "vars.api_result.body.count", "operator": "gt", "value": "0", "value_type": "number" }
{ "field": "vars.api_result.ok", "operator": "is_true", "value_type": "boolean" }
{ "field": "vars.api_result.status", "operator": "eq", "value": "404", "value_type": "number" }
{ "field": "{{vars.order.items[0].sku}}", "operator": "starts_with", "value": "SKU-" }
{ "field": "vars.order.total", "operator": "gte", "value": "{{vars.free_shipping_threshold}}", "value_type": "number" }
{ "field": "trigger.order.created_at", "operator": "within_last_days", "value": "7", "value_type": "date" }
{ "field": "vars.score_result.body.segments", "operator": "contains", "value": "churn_risk" }

Operators (full list):

OperatorApplies toDescription
eq / neq (aliases equals / not_equals)anyEquality after casting (case-insensitive text in auto/string mode).
contains / not_contains / starts_with / ends_withtext, lists (contains)Substring match; element membership on lists.
one_of / not_one_of / contains_anytext, listsMembership in a keyword list (value is a JSON array or a comma-separated string).
lt / lte / gt / gtenumbers, dates, text (string mode)Ordering.
before / after / ondatesDate comparison.
within_last_days / more_than_days_agodatesRelative to now; value is a day count.
is_true / is_falsebooleansTruthiness.
is_empty / is_not_empty / exists / not_existsanyEmptiness / presence (no value).
has_tag / not_has_tagcontact.tagTag membership.
language_is / language_in / language_not / language_not_in / language_identified / language_unidentifiedcontact.languageContact language, see Contact language conditions.
has_contact_method_for_channelcontact.has_methodContact has an identifier for the channel in value.
is_subscribed_to_list / is_not_subscribed_to_listsubscriptionList subscription state.
link_clickedflow.var.<tracked_link_id>True once the tracked link has been clicked. Excludes bot/preview crawlers — see Tracked links.

GET /api/v1/variables/condition-fields returns the curated field list plus a custom_path block (namespaces, value_types, operators_by_value_type) the editor uses to offer the free-path mode. Custom fields (contact.custom.<key>) appear in that list only when your API key also has custom_fields:read; you can still reference them as a free path.

Tiered recheck cadence. When wait_max_seconds > 0, the engine re-evaluates the condition on a tiered schedule: every 10s for the first 10 minutes, every 30s for the next 2 hours, every 60s for the next 24 hours, then every 5 min until the deadline. This keeps fast feedback for click-tracking while remaining cheap for long waits.

Deadline anchoring. wait_max_seconds is anchored on the FIRST evaluation, not on each recheck. A 10-minute timeout fires 10 minutes after the condition node first ran, regardless of how often it has been rechecked.

Tag / Update Contact​

Mutates the contact record without sending a message.

FieldTypeNotes
add_tagsstring[]Tag names to add. Created if they don't exist.
remove_tagsstring[]Tag names to remove.
set_fieldsobjectMap of contact-field name → value. Supports built-in fields (first_name, last_name, email, phone, language, etc.) and custom fields.
languages_addstring[]Language codes to add to the contact's list of spoken languages (languages). Normalised (pt_BR → pt-br) and deduplicated.
languages_removestring[]Language codes to remove from that list. Exact match, case-insensitive: ar does not remove ar-SA.
subscriptionsobject{ "subscribe": [...], "unsubscribe": [...] }. Each entry has list_id and an optional channel_scope (specific with contact_method_type, current, or for unsubscribe all, the default). Changes are audit-logged.

Setting the language. set_fields.language takes an ISO code ("ar", "pt-BR"); it is saved lowercase with -. A literal code set by a flow is treated like one set by a teammate: automatic language detection does not overwrite it. A value filled in from a {{ … }} variable (for example a visitor's answer) is saved too, but detection may still replace it later. null or "" clears the language, and detection may then fill it again. An invalid literal code is rejected at publish; an invalid templated value is skipped at run time. set_fields.language changes the contact's preferred language; languages_add / languages_remove edit the separate list of languages the contact speaks. Both emit the usual contact.updated event. In a test run nothing is written; the run output lists what would change.

Open Conversation​

Creates (or reuses) a real conversation row for the contact on a chosen channel. The conversation appears in the inbox and can be assigned right away.

FieldTypeNotes
channel_typestringOptional. Channel to open the conversation on. Omitted = the channel the run started on.
channel_iduuidOptional. A specific channel.
internal_notestringOptional. Posted as a tenant-internal note on the new conversation. Variable substitution supported.
assign_strategystringHow to assign the conversation. See the table below. Omitted = none.
contact_owner_fallback_strategystringWith contact_owner only: the strategy to use when the contact has no owner or the owner cannot take the conversation. Any strategy except contact_owner; its own required fields apply. Omitted = none (stay unassigned).
also_set_contact_ownerbooleanDefault false. When the node assigned exactly one team member, that member also becomes the contact's owner (not when a substitute or the existing owner was assigned).
assign_user_iduuidRequired for specific_user.
assign_user_idsuuid[]Required (non-empty) for round_robin_in_set.
assign_team_iduuidThe custom inbox to rotate over. Required for round_robin_in_team / least_busy_in_team.
assignment_tag_iduuidRequired for round_robin_by_tag / least_busy_by_tag. Must be a tag in your workspace when the flow is saved and published. If the tag is deleted later, the node skips assignment.
unattended_fallback_minutesintegerOptional, 1..1440, *_by_tag strategies only. If the conversation is still unassigned, or no team member has replied, after this many minutes, workspace admins and the members holding the tag are alerted. Schedule-aware: when the flow trigger has a schedule, its windows are treated as opening hours (in both inside and outside mode, in the workspace default timezone), and an off-hours hand-over starts the countdown at the next window.
prefer_online_agentsbooleanDefault false. Prefer online team members; falls back to everyone eligible if nobody is online.
trigger_conversation_opened_flowsbooleanDefault false. When true, the opened conversation also fires conversation_opened flow triggers.
assign_strategyAssigns to
noneNobody (unassigned).
specific_userassign_user_id.
round_robin_in_team / least_busy_in_teamMembers of the custom inbox assign_team_id, in turn / with the fewest open conversations.
round_robin_in_setThe users in assign_user_ids, in turn.
round_robin_allAll team members, in turn.
round_robin_by_tag / least_busy_by_tagAdds assignment_tag_id to the conversation, then picks among the team members holding that tag, in turn / with the fewest open conversations. Only active members who can see the conversation are eligible. See Skill-based routing.
contact_ownerThe contact's owner. If the owner is out of office, their substitute; if they are outside work hours, the workspace's auto-assignment rule decides. When the contact has no owner, the owner is absent without substitute, cannot see the conversation's inbox, or the rule says to use routing, contact_owner_fallback_strategy runs.

Mostly used for "send 3 messages → if no click, open a conversation so an agent can intervene", or to route a contact to the right specialists after a qualifying question.

Generates a short URL (https://c.sendseven.com/<code>) that redirects to a target URL and counts clicks. The short URL is written into a flow variable for use in downstream Send nodes.

FieldTypeNotes
target_urlstringWhere the short URL redirects to. Variable substitution supported.
output_var_namestringFlow variable to write the short URL into. Default tracked_link_url.

The redirect handler filters bot/preview User-Agents (WhatsApp, Facebook, Telegram, Slack, iMessage, Discord, generic crawlers, headless browsers). Bot fetches go to tracked_links.preview_count; only real-human GETs increment click_count. This means the Condition link_clicked operator stays false until a human clicks — link-preview crawls don't accidentally fire YES.

API Call (Beta)​

Beta

API Call is in Beta. The node works end to end, but field names and limits may still change in backwards-compatible ways. Watch the changelog.

A first-class HTTP client node — the only way a flow makes an outbound HTTP request. It renders every part of the request from variables, supports auth presets and stored secrets, parses the JSON response into a typed variable, and gives you a success / error branch pair with a per-node status expectation. (It replaces the legacy webhook node, which is no longer accepted by the API.)

FieldTypeNotes
method"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"Default GET.
urlstringHTTPS URL, variable substitution supported. http:// only for localhost / *.local. Private / internal targets are rejected at save time (static check) and at run time (DNS resolution, see below).
authobject{"type": "none"} (default), {"type": "bearer", "token": "{{secrets.X}}"}, {"type": "basic", "username", "password"} or {"type": "api_key", "header_name": "X-API-Key", "value"}. All values support substitution.
headers[{key, value}]Request headers. Values support substitution.
query_params[{key, value}]Appended to the URL (URL-encoded).
body_mode"none" | "json" | "form" | "raw"Default none. A body is only sent for POST / PUT / PATCH; the validator rejects any other combination.
body_jsonstringJSON template. A bare {{token}} outside quotes keeps its native type ("id": {{vars.order.id}} → number / object / array), a token inside quotes renders as text. Must parse as JSON once tokens are quoted — checked at save time.
body_form[{key, value}]application/x-www-form-urlencoded body.
body_raw / content_typestringRaw body; content_type defaults to text/plain.
timeout_secondsintegerDefault 10. Range 1..30.
expected_statusstringWhich statuses count as success. Grammar: 2xx, 200, 200-204, comma-separated (2xx,404). Default 2xx.
retry_on_errorbooleanDefault false. When true, a timeout, connection error or 5xx response is handed back to the queue for retry with backoff instead of taking the error branch immediately. 4xx never retries.
output_varstringFlow variable that receives the result. Default api_result.
run_in_test_modebooleanDefault true. Set false to skip the real request inside Flow test runs — the node then succeeds with status: 0 and an empty body and the step audit shows what would have been called. Ignored by the editor's "Test request" button, which always makes the real call.

Output. {{vars.<output_var>}} is an object:

{
"ok": true,
"status": 200,
"headers": {"content-type": "application/json"},
"body": {"order": {"id": 42, "items": [...]}},
"duration_ms": 183,
"error": null
}

body is the parsed JSON when the response is JSON, otherwise the response text. Downstream nodes address into it with nested paths — {{vars.api_result.body.order.items[0].sku}} — and Condition nodes can branch on vars.api_result.status. On failure ok is false and error is {"code", "message"} with one of timeout, connection_error, unexpected_status, response_too_large, ssrf_blocked, invalid_url, invalid_body, invalid_config, rate_limited, run_cap_exceeded.

Branches. Wire success (required to publish) and error (a missing error edge is a publish warning: if the call fails the run stops with an error). The output variable is written on both branches, so the error path can still read status and body.

Secrets. Reference stored secrets as {{secrets.NAME}} in the URL, headers, auth and body. Manage them under Flows → Secrets or via PUT /api/v1/flows/secrets/{NAME} — see Triggers and the API. Publishing fails if a referenced secret does not exist. Secret values are redacted (***) in the step audit, in output_snapshot and in the response headers / body echoed back to you.

Testing in the editor. The Test request button runs the node once against a sample contact (or a contact you pick) without starting a flow run. Before running you can seed the test with sample flow variables (for example an api_result object a previous node would have produced), a sample last inbound message that fills {{message.text}} / {{vars.last_inbound}}, and a sample trigger payload for {{trigger.*}}; optionally a real conversation of that contact backs {{conversation.*}}. The result tells you which inputs were used (real or sample). Secret values stay redacted.

Limits & safety. Response bodies are capped at 1 MiB (larger responses fail with response_too_large). Redirects are not followed — a 3xx is just a status you can accept via expected_status. At most 20 API calls per run and 600 per workspace per minute. Run-time SSRF protection resolves the host, refuses if any record is private / loopback / link-local, then connects to the vetted IP with the original Host header and TLS SNI, so a DNS rebind between check and connect cannot redirect the request.

Custom Code (Beta)​

Beta

Custom Code is in Beta. The sandbox contract (what inputs contains, what a script may return) is stable; limits may be tuned.

Runs a short JavaScript snippet in an isolated sandbox and merges the returned object into the flow variables. Use it to reshape an API response, compute a value, or build a string that is awkward to express with plain variable substitution.

FieldTypeNotes
codestringJavaScript. Max 64 KB. Must return a plain object.
timeout_secondsintegerDefault 5. Range 1..10.
declared_outputsstring[]Names the script returns. Used by the editor's variable picker only — the engine merges whatever the script actually returns.
error_varstringFlow variable that receives {name, message, line?, column?} when the script fails. Default code_error.

Inputs. The script sees one read-only global, inputs, with the same namespaces as {{...}} templates: inputs.vars, inputs.contact, inputs.message, inputs.conversation, inputs.trigger, inputs.flow. Secrets are not available to code. console.log output is captured (max 100 lines) and shown in the step audit.

const order = inputs.vars.api_result.body.order;
const total = order.items.reduce((s, i) => s + i.qty * i.price, 0);
console.log("items", order.items.length);
return { order_total: total.toFixed(2), first_sku: order.items[0]?.sku ?? "" };

Return value. Must be a plain object (max 64 KB after JSON encoding); every key becomes {{vars.<key>}}. Keys must be valid variable names (^[a-zA-Z_][a-zA-Z0-9_]*$, not starting with __). Returning anything else fails the node with InvalidReturnError / InvalidOutputKeyError / OutputTooLargeError.

Sandbox. No require, process, fetch, filesystem or network — the node is pure computation over inputs. Use API Call for HTTP. Scripts that exceed the timeout fail with TimeoutError; if the sandbox itself is unreachable the node fails with RunnerUnavailable.

Testing in the editor. The Run script button executes the code once in the sandbox against a sample contact (or a contact you pick). Seed inputs.vars, inputs.message.text and inputs.trigger with sample values before running so the script sees the data it will get in a real run; the result shows the returned object, console output, duration, any error with line and column, and which inputs (real or sample) were used.

Branches. Same success / error pair as API Call: success is required to publish, a missing error edge is a publish warning. At most 20 code executions per run.

Run AI Assistant​

Hands the conversation to an AI bot for a bounded conversational sub-loop. The target is either a classic FAQ Bot (a Knowledge-Base-backed bot) or a Conversational Agent (Beta). The bot answers the contact's messages until an exit condition fires, then the flow resumes on the matching branch.

FieldTypeNotes
targetobject{"type": "bot" | "agent", "id": "<uuid>"}. Takes precedence over bot_id. For agent, id is the agent's id and is required to publish.
bot_iduuidLegacy way to pick an FAQ Bot. Used when target is not set. A bot_id that belongs to a Conversational Agent also runs as that agent.
max_turnsinteger | nullAnswered contact turns before the completed branch fires. null = unlimited (use with care). Valid range: 1..200.
allow_stop_keywordbooleanIf true, the contact can type one of the bot's escalation keywords (e.g. "human") to leave the loop via escalated.
timeout_enabled / timeout_secondsboolean / integerWhen enabled, the timeout branch fires if the contact does not reply within timeout_seconds. Keep it within the channel's session window (24h on WhatsApp) to avoid needing a template on resume.
escalation_thresholdintegerNumber of unanswerable attempts before the bot escalates, for this node only. 0 turns escalation off for this node. Unset = the bot's own setting.
treat_handoff_text_as_first_messagebooleanDefault false. When true, the bot sends its greeting and then answers the contact's most recent message before the node (the message that started the flow, or a button tap or reply earlier in the run) as turn 1 of max_turns. The message does not need text: a voice message, image or PDF is forwarded when the bot can act on it (a Conversational Agent reads images and PDFs and transcribes voice messages when voice reading is on, otherwise it replies that it cannot listen to voice messages; an FAQ Bot reads images with image reading on and asks for text on a voice message). Stickers, GIFs, videos and other files without text are not forwarded and do not count as a turn. No effect for routing, lead-qualification or appointment bots.

Agent-only fields. With target.type: "agent" the node can give the agent a task and collect structured results:

FieldTypeNotes
skill_slugstringStart the agent with this skill loaded. Same format as a skill slug.
goal_sourcestringskill, node or none. skill uses the selected skill's goal instead of goal_prompt/output_schema; node uses the node's own task; none never uses a skill goal. When not set: skill if the node defines neither goal_prompt nor output_schema properties, otherwise node. See Agents in Flows.
goal_promptstringWhat the agent should achieve. Max 4,000 characters. Flow variables are substituted.
input_variablesobjectUp to 30 values passed to the agent. Keys: ^[A-Za-z_][A-Za-z0-9_]{0,63}$. Values: text (max 2,000 characters, variables substituted), number, boolean or null.
output_schemaobjectThe results the agent must return. See below.
output_variable_prefixstringWhere the results are written: vars.<prefix>.<key>. Default agent. Pattern ^[A-Za-z][A-Za-z0-9_]{0,63}$; reserved variable names are rejected.

output_schema is a small JSON Schema object (max 8 KB):

{
"type": "object",
"description": "Return request details",
"properties": {
"order_number": {"type": "string", "description": "The order to return"},
"reason": {"type": "string", "enum": ["damaged", "wrong_size", "other"]},
"items": {"type": "integer"},
"refund": {"type": "boolean"}
},
"required": ["order_number", "reason"]
}
  • Max 20 properties. Names match ^[A-Za-z][A-Za-z0-9_]{0,63}$.
  • Each property has a type of string, number, integer or boolean, an optional description (max 500 characters) and an optional enum (1-50 scalar values).
  • required may only list declared properties.

When the agent has collected everything, it completes the task and the node leaves on the done branch. The results are available to every later node as {{vars.<prefix>.<key>}}, for example {{vars.agent.order_number}}. Only declared properties with scalar values are written (text is cut at 4,000 characters), and they are merged into the existing variables.

Branches.

BranchFires when
completedThe bot answered max_turns turns, or ended the session itself.
doneAgent targets only. The agent completed its task; outputs are in vars.<prefix>.*. If not wired, the run continues on completed. Wiring done on a non-agent target is rejected when saving.
escalatedThe bot handed the conversation to a human, or the contact used a stop keyword. Also taken when the target cannot start: the bot is missing or inactive, or the plan does not include Conversational Agents (error plan_not_allowed). Falls back to completed when not wired.
timeoutNo contact reply within timeout_seconds, or the session could not be set up.

Missing branches are publish-time warnings, not errors. Publishing also warns when two output property names differ only in case. Wire every branch for predictable production behavior.

KB Query​

Searches the workspace knowledge base and produces two things at once: the matched source chunks and a grounded, synthesized natural-language answer built from them. Use it to answer a contact's question from your own content — either from the message they just sent, or from an answer you ask them for first.

FieldTypeNotes
querystringThe search text. Defaults to {{vars.last_inbound}} (the contact's latest inbound message). Full variable substitution supported — {{vars.*}}, {{contact.*}}, etc. The legacy {{contact.last_message}} token still resolves as an alias for the last inbound message.
output_var_namestringBase name for the result variables (referred to as <out> below).
context_mode"last_message" | "full_conversation"Default last_message. Controls how much context backs the search — see below.
min_confidenceintegerConfidence threshold, 0..100. Default 50. Matches at or above it route to match; below it (or zero matches) route to no_match.
ask_firstbooleanDefault false. When true, the node asks the contact a question first and searches their reply instead of the incoming message — see Feeding the query below.
promptstringOnly used when ask_first is on. The question sent to the contact before searching. Same variable substitution as Send.
timeout_secondsintegerOnly used when ask_first is on. How long to wait for the contact's reply before taking the timeout branch. Default 24h (86400).
channel_chainstring[]Only used when ask_first is on. Where to send the prompt. Falls back to the flow's default channel chain if omitted.

Outputs. Given an output variable name <out>, the node writes:

VariableContents
<out>The matched chunks / sources (the raw retrieval result).
<out>_answerThe grounded, natural-language answer synthesized from the matches. This is the usable reply — drop it straight into a following Send Message node as {{vars.<out>_answer}}.
<out>_confidenceInteger 0..100 — how confident the retrieval was.
<out>_countNumber of matches found.
<out>_textConcatenated text of the matched chunks.

Context mode. last_message (default) searches using only the contact's last message — fastest and best when each question stands on its own. full_conversation builds a bounded transcript of the recent conversation and uses it as context for the search, which helps when the contact's latest message is a follow-up that only makes sense together with what came before ("and what about the annual plan?").

Confidence threshold. min_confidence decides which branch runs: a result whose confidence is at or above the threshold takes match; anything below it — including a search that returns zero matches — takes no_match. Raise it to only answer when the KB is a strong fit; lower it to answer more often.

Branches.

BranchFires when
matchA match at or above min_confidence was found. Required to publish.
no_matchNo match, or the best match was below min_confidence. Recommended — a missing no_match edge is a publish warning.
timeoutOnly when ask_first is on: the contact did not reply within timeout_seconds. Required to publish when ask_first is enabled.

Feeding the query. There are two ways to give the node something to search:

  • (a) Default — search the incoming message. With ask_first off, the node searches query (by default {{vars.last_inbound}}, the message the contact just sent). Use this in a bot-style flow where the contact has already asked a question.
  • (b) ask_first — ask, then search the answer. With ask_first on, the node first sends prompt to the contact (over channel_chain, or the flow's default chain), waits for their reply, and uses that reply as the search query. Use this to proactively offer help ("What can I help you find?") and then answer from the KB. This mode adds the timeout branch for the case where the contact never replies.

Start Flow​

type: "start_flow". Starts another flow for the same contact (and the same conversation, when the run has one). In wait mode the parent run parks until the child run ends, then branches on the outcome. In fire_and_forget mode the parent continues immediately and the two runs are independent.

FieldTypeNotes
target_flow_iduuidThe flow to start. Required to publish. Must be in the same workspace, published, have manually_triggerable = true, and not be the flow itself. A draft target is accepted only while the parent run is a Test-tool run.
mode"wait" | "fire_and_forget"Default wait.
timeout_enabledbooleanDefault false. Wait mode only.
timeout_secondsinteger60..2592000 (1 minute to 30 days). Required to publish when timeout_enabled is true.
on_timeout"cancel_child" | "let_run"Default cancel_child. What happens to the child when the timeout fires.
inherit_varsbooleanDefault false. Copy the parent's flow variables into the child. Internal keys are never copied.
input_vars[{key, value}]Up to 50 rows. key must match ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$ and be unique. value supports variable substitution. Input vars win over inherited vars.
result_variablestringDefault start_flow_result. Name of the flow variable the result object is written to.
output_mapping[{from, to}]Up to 50 rows. Copies vars.<from> from the ended child into vars.<to> on the parent (dotted paths allowed in from). Wait mode only.

Inherited vars, rendered input_vars and the copied trigger payload are each capped at 32 KB of JSON: whole top-level keys that would exceed the cap are dropped (never partially cut), the child's context.subflow.truncated lists which parts were cut, and the start still succeeds. The child run receives the parent's trigger payload, context.trigger.type = "start_flow" with context.trigger.parent = {run_id, flow_id, node_id}, and the parent's per-run contact-method overrides.

Branches.

ModeBranchFires when
waitcompletedThe child completed. Falls back to default if completed is not wired. One of the two is required to publish.
waitfailedThe child failed or was canceled, its flow was deleted, or it could not be started. Not wired: publish warning, and at runtime the parent run ends at this node as completed (no error).
waittimeouttimeout_enabled and the child did not end within timeout_seconds. Not wired: publish warning, and at runtime the parent run ends at this node as completed (it does not fall back to failed). A timeout edge without a timeout is also a publish warning.
fire_and_forgetdefaultThe child was started. Required to publish.
fire_and_forgetfailedOptional. The child could not be started. Falls back to default.

Result object (written to vars.<result_variable>):

{
"status": "completed",
"reason": null,
"run_id": "b9c1…",
"flow_id": "4f2e…",
"ended_at": "2026-10-02T09:14:03+00:00",
"vars": { "address": "Main St 1" }
}

status is one of completed, failed, canceled, timeout, not_started (a guard stopped the start) or started (fire-and-forget success). vars is the child's final variables (wait mode only, capped at 32 KB). The child's raw error text is never copied into the result; it appears only as detail in the step's output_snapshot. reason is one of these stable values:

ReasonMeaning
target_not_startableTarget is not manually startable, or is the flow itself.
target_unavailableTarget missing, deleted or not published.
cycleTarget is already in this run's chain of parent flows.
max_depthThe chain is already 5 levels deep.
already_runningThe target's re-entry policy is block and the contact has an active run of it. (restart cancels the old run; parallel starts another.)
fanout_limitThis run has already started 20 child runs, or the whole run tree (all levels below the top-level run) has already created 100 runs. Also returned, fail-closed, if the tree's budget cannot be checked (the top-level run no longer exists).
plan_not_allowedThe workspace's plan does not include flows.
child_failedThe child run failed.
child_canceledThe child run was canceled.
timeoutThe wait timed out.
target_deletedThe child's flow was permanently deleted while the parent waited (status failed).
child_missingThe child run no longer exists and its end was never delivered; the parent was resumed by the periodic consistency check (status failed).

Lifecycle rules.

  • If a parent in wait mode ends for any reason (stop, cancel, agent takeover, error), its waiting child is canceled with canceled_reason = "parent_ended". This cascades down the chain.
  • fire_and_forget children are never canceled by their parent.
  • If a wait-mode child is left running after its parent ended (for example, the cancel was interrupted), a periodic consistency check cancels it. A child released by an on_timeout: let_run timeout is left running.
  • Permanently deleting a flow first cancels its in-flight runs: parents waiting on them continue on failed with reason target_deleted, and their own wait-mode children are canceled.
  • In the runs API, a child run's context.subflow carries parent_run_id and root_run_id. The step's output_snapshot exposes child_run_id. (Flow runs do not emit customer webhooks.)
  • Publishing re-checks every referenced target and rejects the publish if one is missing, unpublished, or not manually startable.

Send WhatsApp Flow​

type: "send_whatsapp_flow". Sends a published WhatsApp Flow (a native WhatsApp form) to the contact of the run, waits until the contact submits it, and continues with the answers. Needs a plan that includes WhatsApp Flows; flows with a live data endpoint additionally need dynamic flows (Scale and higher).

FieldTypeNotes
wa_flow_definition_iduuidThe WhatsApp Flow to send. Required to publish. Must exist in the workspace, not be archived, and be published on the WhatsApp Business Account of the sending channel.
channel_iduuid | "auto"The WhatsApp channel. Empty or "auto": the trigger's channel, then the run's conversation, then the contact's most recent WhatsApp conversation.
header, body, footer, ctastringTexts of the interactive message. Support variable substitution. body defaults to "Tap the button below to continue." and cta (button text, max 30 characters) to "Get started", both in the contact's language (else the workspace default language, else English).
template_namestringOptional. An approved template with a Flow button for this flow, sent when the 24-hour service window is closed.
template_languagestringOptional template language code.
template_paramsobject | arrayOptional template variables. Support variable substitution.
timeout_secondsinteger60..259200 (1 minute to 72 hours). Default 86400 (24 hours).
initial_data[{key, value}]Optional data for the flow's entry screen. value supports variable substitution.
output_keystringWhere the result is stored: vars.waflow.<output_key>. Defaults to the node id. Must be unique within the flow.

Branches.

BranchFires when
completedThe contact submitted the flow. Falls back to default if completed is not wired. One of the two is required to publish.
timeoutNo submission within timeout_seconds.
fallbackThe flow could not be sent. See reason below.

An unwired timeout or fallback ends the run at this node as completed (no error), like the Start Flow node. Publishing warns when fallback is not wired or no template_name is set.

Result (vars.waflow.<output_key>): every submitted answer by its input name, plus:

KeyValue
statuscompleted, timeout, fallback, or simulated (Test-tool runs: nothing is sent, the node continues on completed with no answers).
completed_atISO-8601 time of the submission (completed only).
session_id, definition_idThe WhatsApp Flow session and flow.
reasonWhy timeout or fallback fired: timeout, not_configured, definition_unavailable, plan_not_allowed, no_channel, outside_service_window (window closed and no template), send_failed.

These keys win over answers with the same name, so avoid input names like status or reason.

{
"id": "ask_callback",
"type": "send_whatsapp_flow",
"config": {
"wa_flow_definition_id": "0f9e8d7c-…",
"channel_id": "auto",
"body": "Hallo {{contact.first_name}}, wann dürfen wir dich anrufen?",
"cta": "Termin wählen",
"template_name": "rueckruf_flow",
"template_language": "de",
"timeout_seconds": 172800,
"output_key": "callback"
}
}

The next node can use {{vars.waflow.callback.slot}} or branch on vars.waflow.callback.budget.

  • A submission that arrives after the timeout is still stored and written back to the contact, and fires the whatsapp_flow.completed trigger; the waiting run has already moved on.
  • If a submission resumes a waiting run, the whatsapp_flow.completed trigger does not start a second flow for it, unless that trigger sets include_automation_sessions: true.
  • Mapped answers are written to the contact as configured in the WhatsApp Flow (field mapping), independent of this node.

Stop / Terminate​

Explicitly ends the run. Useful for "if condition X, stop here" branches that should not fall through to the rest of the flow.

FieldTypeNotes
reasonstringOptional. Stored on flow_runs.stop_reason for analytics.