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>}}, {{flow.var.<name>}}, and {{flow.stop_url}}. 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.

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

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 (ltgte 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.

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.

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.
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/flows/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.

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, etc.) and custom fields.
subscription_changesobject[]Each entry: { "channel_type": "whatsapp", "status": "subscribed" | "unsubscribed" }. Routes through SubscriptionService (audit-logged).

Open Conversation

Creates (or reuses) a real conversation row for the contact on a chosen channel. The conversation appears in the inbox and follows your normal assignment rules.

FieldTypeNotes
channel_typestringChannel to open the conversation on. Must match a channel the contact has a method for.
internal_notestringOptional. Posted as a tenant-internal note on the new conversation. Variable substitution supported.
assign_user_iduuidOptional. Direct-assigns the conversation to a team member.
assign_teamstringOptional. Routes to a team's queue.

Mostly used for "send 3 messages → if no click, open a conversation so an agent can intervene".

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 off to an AI Assistant (a Knowledge-Base-backed bot) for a bounded conversational sub-loop. The bot answers the contact's messages until one of three exit conditions fires, then the flow resumes on the matching branch.

FieldTypeNotes
bot_iduuidThe Assistant to run. Must be active.
max_turnsinteger | nullMaximum number of user→bot exchanges before the completed branch fires. null = unlimited (use with care). Valid range: 1..200.
allow_stop_keywordbooleanIf true, the contact can type a stop keyword (e.g., "human", "agent") to short-circuit the loop and take the escalated branch. Uses the bot's configured escalation keywords.
timeout_secondsintegerIf no contact reply arrives within this window, the timeout branch fires. No upper cap on the server side, but keep it within the channel's session window (24h for WhatsApp) to avoid template-send requirements on resume.
escalation_thresholdfloatConfidence below which the bot escalates. Persisted as 0.0..1.0; the UI surfaces it as a 0..100 % slider.

Required branches. The node must wire three outgoing edges:

BranchFires when
completedThe bot answered the user's question, hit max_turns, or the bot itself decided it was done.
timeoutNo user reply within timeout_seconds.
escalatedThe bot's confidence dropped below escalation_threshold, or the contact used a stop keyword (when allow_stop_keyword is on).

Missing branches surface as publish-time warnings — the flow can be saved without all three wired, but POST /publish will warn about the unrouted exits. Adding all three is required for predictable production behavior.

warning

Current limitation: the bot integration service does not yet count turns. Setting max_turns <= 1 is treated as "cap reached on turn 1" — the loop exits via completed after the first reply. If you need a strict one-shot bot response, this is the right setting; if you intended multi-turn behavior, set max_turns >= 2 once turn counting lands.

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.