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:
| Field | Type | Notes |
|---|---|---|
display_name | string | Human-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.
"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.
| Field | Type | Notes |
|---|---|---|
channel_chain | string[] | 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_content | object | Per-channel payloads. Each key is a channel type; each value is the message body for that channel. |
body_text | string | Legacy / fallback. Used if channel_content is empty. |
subject | string | Email only. Required for email sends. |
email_mailbox_id | uuid | Email only. Picks the From mailbox. |
template_id | uuid | WhatsApp only. References an APPROVED WhatsApp template. |
template_variables | object | WhatsApp template variable values, including variable_fallbacks for nullable fields. |
header_image_url | string | WhatsApp template header image. Pre-uploaded to Meta as a 30-day media ID. |
include_signature | boolean | Email only. Default true. Appends the user's HTML signature. |
buttons | object[] | 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.
| Field | Type | Notes |
|---|---|---|
duration | integer | Wait duration, in the chosen unit. |
unit | "seconds" | "minutes" | "hours" | "days" | Time unit. The UI surfaces all four; the engine normalizes to seconds internally. |
duration_seconds | integer | Read-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.
| Field | Type | Notes |
|---|---|---|
prompt | string | Message text shown to the contact. Same variable substitution as Send. |
output_var_name | string | Flow variable to write the reply into. Reference downstream as {{vars.<name>}}. |
timeout_seconds | integer | Optional. If no reply arrives within this window, the engine takes the timeout branch (configured via an outgoing edge with branch: "timeout"). |
channel_chain | string[] | 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).
| Field | Type | Notes |
|---|---|---|
rules | Rule[] | Each rule has field, operator, value and an optional value_type. See below. |
rules_op | "all" | "any" | How rules combine. Default all. |
wait_max_seconds | integer | Optional. 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_type | Casts | Operators | Cast 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. | all | falls back to text comparison |
string | Both 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) | — |
number | Both sides → float ("42", " 3.5 " accepted; booleans are not numbers). | eq neq lt lte gt gte is_empty is_not_empty exists not_exists | rule is false |
boolean | true/yes/1/on → true, false/no/0/off/"" → false. | is_true is_false eq neq exists not_exists | rule is false |
date | ISO 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_exists | rule 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
fieldmust be a curated key or a parseable path in a known namespace (secrets.x,foo.barand malformed paths are rejected).value_typemust be one ofauto,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):
| Operator | Applies to | Description |
|---|---|---|
eq / neq (aliases equals / not_equals) | any | Equality after casting (case-insensitive text in auto/string mode). |
contains / not_contains / starts_with / ends_with | text, lists (contains) | Substring match; element membership on lists. |
one_of / not_one_of / contains_any | text, lists | Membership in a keyword list (value is a JSON array or a comma-separated string). |
lt / lte / gt / gte | numbers, dates, text (string mode) | Ordering. |
before / after / on | dates | Date comparison. |
within_last_days / more_than_days_ago | dates | Relative to now; value is a day count. |
is_true / is_false | booleans | Truthiness. |
is_empty / is_not_empty / exists / not_exists | any | Emptiness / presence (no value). |
has_tag / not_has_tag | contact.tag | Tag membership. |
has_contact_method_for_channel | contact.has_method | Contact has an identifier for the channel in value. |
is_subscribed_to_list / is_not_subscribed_to_list | subscription | List subscription state. |
link_clicked | flow.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.
| Field | Type | Notes |
|---|---|---|
add_tags | string[] | Tag names to add. Created if they don't exist. |
remove_tags | string[] | Tag names to remove. |
set_fields | object | Map of contact-field name → value. Supports built-in fields (first_name, last_name, email, phone, etc.) and custom fields. |
subscription_changes | object[] | 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.
| Field | Type | Notes |
|---|---|---|
channel_type | string | Channel to open the conversation on. Must match a channel the contact has a method for. |
internal_note | string | Optional. Posted as a tenant-internal note on the new conversation. Variable substitution supported. |
assign_user_id | uuid | Optional. Direct-assigns the conversation to a team member. |
assign_team | string | Optional. Routes to a team's queue. |
Mostly used for "send 3 messages → if no click, open a conversation so an agent can intervene".
Create Tracked Link
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.
| Field | Type | Notes |
|---|---|---|
target_url | string | Where the short URL redirects to. Variable substitution supported. |
output_var_name | string | Flow 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)
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.)
| Field | Type | Notes |
|---|---|---|
method | "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | Default GET. |
url | string | HTTPS 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). |
auth | object | {"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_json | string | JSON 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_type | string | Raw body; content_type defaults to text/plain. |
timeout_seconds | integer | Default 10. Range 1..30. |
expected_status | string | Which statuses count as success. Grammar: 2xx, 200, 200-204, comma-separated (2xx,404). Default 2xx. |
retry_on_error | boolean | Default 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_var | string | Flow variable that receives the result. Default api_result. |
run_in_test_mode | boolean | Default 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)
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.
| Field | Type | Notes |
|---|---|---|
code | string | JavaScript. Max 64 KB. Must return a plain object. |
timeout_seconds | integer | Default 5. Range 1..10. |
declared_outputs | string[] | Names the script returns. Used by the editor's variable picker only — the engine merges whatever the script actually returns. |
error_var | string | Flow 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.
| Field | Type | Notes |
|---|---|---|
bot_id | uuid | The Assistant to run. Must be active. |
max_turns | integer | null | Maximum number of user→bot exchanges before the completed branch fires. null = unlimited (use with care). Valid range: 1..200. |
allow_stop_keyword | boolean | If 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_seconds | integer | If 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_threshold | float | Confidence 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:
| Branch | Fires when |
|---|---|
completed | The bot answered the user's question, hit max_turns, or the bot itself decided it was done. |
timeout | No user reply within timeout_seconds. |
escalated | The 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.
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.
| Field | Type | Notes |
|---|---|---|
reason | string | Optional. Stored on flow_runs.stop_reason for analytics. |