Flow format spec
This page describes every part of a flow definition: node types, their fields and allowed values, condition operators, variables, edges and triggers. It is generated from the same rules the API uses to validate flows, so a definition that follows it saves cleanly.
Use it to:
- read a flow as JSON with
GET /api/v1/flows/{flow_id}(thedefinitionfield), - create or update flows with
POST /api/v1/flowsandPATCH /api/v1/flows/{flow_id}(same validation as a save in the builder), - let an AI write the JSON for you (copy the prompt at the end).
Top-level shape
{
"schema_version": 1,
"trigger": {
"type": "keyword_match",
"config": {
"keywords": [
"hi"
],
"match_mode": "contains"
}
},
"entry_node_id": "first_step",
"nodes": [
{
"id": "first_step",
"type": "send",
"display_name": "Greeting",
"config": {}
}
],
"edges": [
{
"from": "first_step",
"to": "next_step",
"branch": "default"
}
]
}
| Key | Notes |
|---|---|
schema_version | Always 1. |
trigger | {type, config}. See Triggers. |
entry_node_id | The first action node. It must not be a trigger node. |
nodes | Array of {id, type, config}. id is any unique string. |
edges | Array of {from, to, branch}. branch must be a label of the source node type. |
inbound_behavior | ignore / stop_and_open_conversation (what an inbound message does while the run waits). |
allow_stop_keyword, stop_keyword_response | Let the contact stop the flow with a keyword; the response is Send-shaped. |
global_timeout_enabled, global_timeout_seconds, global_timeout_node_id | Move an inactive run to a node after 60..2592000 seconds. |
suppress_inbound_conversation | Boolean. Do not open an inbox conversation for messages that start this flow. |
Per node, only id, type, config and the optional display_name (unique per flow) matter. position ({x, y}) is optional and only affects where the builder draws the node; a missing position never causes a validation error.
Limits: the definition may be at most 256 KB and nested at most 64 levels deep.
Channels are not set per node. The flow's default_channel_chain (a field of the flow, next to definition) sets the channel order, using email / instagram / live_chat / messenger / rcs / sms / telegram / whatsapp.
Node types
Allowed type values: api_call / branch / choice / close_conversation / code / collect_input / condition / create_tracked_link / delay / end_flow / hubspot_set_field / kb_query / open_conversation / run_assistant / send / send_email / send_template / send_whatsapp_flow / start_flow / tag. Any other type is rejected.
api_call
Calls an external HTTPS API and stores the response.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
method | DELETE / GET / HEAD / PATCH / POST / PUT | yes | |
url | https URL (may use variables) | yes | Private, loopback and metadata addresses are refused. |
auth | {type, ...} | no | type: api_key / basic / bearer / none. Put credentials in {{secrets.NAME}}, never inline. |
headers | array of {key, value} | no | |
query_params | array of {key, value} | no | |
body_mode | form / json / none / raw | no | |
body_json | JSON (may use variables) | no | |
timeout_seconds | integer 1..30 | no | |
output_var | variable name | no | Response at {{vars.<name>.status}}, {{vars.<name>.body...}}. |
retry_on_error | boolean | no |
Branches: default / error / success.
branch
Multi-way switch: the first case whose rules match wins, otherwise else.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
cases | array (1..20) of {rules_op, rules[], label?} | yes | Same rule shape as the Condition node. |
else_node_id | node id | no | Alternative to an else edge. |
Branches: case:0, case:1, ... (0-based, one per case) and else (default is an alias of else).
choice
Asks a question with 2 to 10 buttons and routes on the picked option.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
prompt | Send-shaped object (messages[], see the Send node) | yes | |
options | array (2..10) of {id, label} | yes | Each id becomes an edge branch. |
attempt_timeout_seconds | integer 60..604800 | no | Default 86400. |
on_timeout_node_id | node id | no |
Branches: One edge per option id, plus fallback (unrecognised reply) and timeout.
close_conversation
Closes the current conversation and continues.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
reason | string | no |
Branches: default.
code
Runs a short sandboxed script over the flow variables.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
code | string, max 64 KB | yes | No network or file access. |
timeout_seconds | integer 1..10 | no | |
declared_outputs | array of variable names | no | Values the script returns into vars. |
error_var | variable name | no |
Branches: default / error / success.
collect_input
Asks a question, waits for the reply, validates it and stores it.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
prompt | Send-shaped object (messages[], see the Send node) | yes | The question. Never put a channel_chain inside it. |
target_field | contact.<column>, contact.custom.<key> or flow.var.<key> | no | Built-in columns: contact.birthday / contact.country / contact.email / contact.first_name / contact.language / contact.last_name / contact.phone. |
validation | {type, ...} | no | type: date / email / enum / file / number / phone / regex / text. For file, accepted_kinds is a subset of any / audio / document / image / video. |
validation_error_message | Send-shaped object (messages[], see the Send node) | no | Sent when the reply is invalid. |
max_attempts | integer 1..10 | no | |
attempt_timeout_seconds | integer 60..604800 | no | Default 86400 (24 hours). |
on_timeout_node_id | node id | no |
Branches: default (valid answer) and timeout. With buttons: one edge per button id plus fallback.
condition
Binary yes/no gate over one or more rules.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
rules_op | all / any | no | Default all. |
rules | array of rule objects (non-empty) | yes | Each rule: field, operator, value (omitted for value-less operators), optional value_type. See Condition rules below. |
wait_max_seconds | integer 0..1209600 | no | Re-check for up to this long before taking no. |
Branches: yes and no (both required at publish). Legacy aliases accepted: 0 / 1 / default / false / true.
create_tracked_link
Creates a click-tracked short link and stores it in a flow variable.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
original_url | URL (may use variables) | yes | |
output_var_name | variable name | yes | Read it later as {{vars.<name>}}. |
link_label | string, max 255 | no | |
expires_in_days | integer 1..3650 | no |
Branches: default. Use the Condition operator link_clicked on flow.var.<name> to branch on a click.
delay
Pauses the run without blocking (scheduler based), from seconds to weeks.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
mode | time / event | no | Default time. |
duration_seconds | integer, 0 or more | yes for time | How long to wait. |
event | reply | no | event mode only: wait for the contact's next reply. |
channel_type | string | no | event mode only: only a reply on this channel type resumes. |
timeout_seconds | integer | no | event mode only: give up after this many seconds. |
Branches: default.
end_flow
Ends the run.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
reason | string | no | |
close_conversation | boolean | no | Default false. |
Branches: None (terminal).
hubspot_set_field
Writes a property on the linked HubSpot contact.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
property_name | HubSpot property | yes | |
value | string (may use variables) | yes | |
output_var | variable name | no |
Branches: default / error / no_mapping / success.
kb_query
Searches the Knowledge Base and stores the answer.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
query | string (may use variables) | yes | |
folder_ids | array of folder ids | no | |
top_k | integer 1..10 | no | |
output_var | variable name | yes | Read it later as {{vars.<name>}}. |
min_confidence | number | no | |
ask_first | boolean | no | Ask the contact for the question first (uses prompt). |
timeout_seconds | integer | no |
Branches: default / match / no_match / timeout.
open_conversation
Opens (or reuses) a conversation in the inbox and optionally assigns it.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
assign_strategy | contact_owner / least_busy_by_tag / least_busy_in_team / none / round_robin_all / round_robin_by_tag / round_robin_in_set / round_robin_in_team / specific_user | no | Default none. |
assign_user_id | team member id | for specific_user | Must be a member of this workspace. |
assign_team_id | inbox id | for team strategies | Must be an inbox of this workspace. |
assignment_tag_id | member tag id | for skill routing | |
prefer_online_agents | boolean | no | |
add_conversation_tags | array of tag names | no | |
remove_conversation_tags | array of tag names | no | |
unattended_fallback_minutes | integer 1..1440 | no | Tag-based strategies only: re-route when nobody has picked the conversation up in time. |
Branches: default.
run_assistant
Hands the conversation to an AI bot or Conversational Agent until it finishes.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
bot_id | bot id | yes for a bot | Must be a bot of this workspace. |
target | {type, id} | no | type: agent / bot. |
timeout_seconds | integer | no | |
max_turns | integer 1..200 | no | |
allow_stop_keyword | boolean | no | |
output_variable_prefix | identifier | no | Prefix for the values the assistant extracts. |
Branches: completed / default / done / escalated / timeout. done is only valid when the target is an agent.
send
Sends one or more messages to the contact over the flow's channel chain.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
messages | array of message objects | yes (unless channel_content covers every channel) | Each message: type (audio / document / image / interactive / text / video), text, order (integer, required), delay_seconds (0..15, required), optional media_url or attachment_id (required for media types at publish), media_caption, buttons[] (id, label). |
channel_content | object keyed by channel type | no | Per-channel override: {"whatsapp": {"messages": [...]}}. Channels without an entry use messages. |
button_wait_seconds | integer 60..604800 | no | Only when a message has buttons: how long to wait for a click. |
timeout_enabled | boolean | no | Default true. False waits for a click without a deadline. |
on_timeout_node_id | node id | no | Where to continue when the button wait times out. |
Branches: default (next step). With buttons: one edge per button id, plus timeout.
send_email
Sends an email from one of the workspace's mailboxes.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
to | email address or variable | no | Default is the contact's email (to_source). |
to_source | string | no | |
subject | string (may use variables) | yes | |
email_mailbox_id | mailbox id | yes at publish | |
body_mode | string | no | Plain text, HTML or a saved email design. |
body_html | string | no | |
body_text | string | no | |
email_content_id | saved email design id | no |
Branches: default.
send_template
Sends an approved WhatsApp template (works outside the 24-hour window).
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
template_id | WhatsApp template id | yes | Must be a template of this workspace. |
template_variables | object | no | Values for the template placeholders (may use variables). |
buttons | array of {index, caption} | no | Snapshot of the template's quick-reply buttons. |
attempt_timeout_seconds | integer 60..604800 | no | |
on_timeout_node_id | node id | no |
Branches: tplbtn_<index> per button, fallback, timeout, default.
send_whatsapp_flow
Sends a WhatsApp Flow (form) and waits for the submission.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
whatsapp_flow_id | WhatsApp Flow id | yes | Must be a WhatsApp Flow of this workspace. |
output_key | variable name | no | Submitted answers land under this key. |
attempt_timeout_seconds | integer 60..604800 | no |
Branches: completed / default / fallback / timeout.
start_flow
Starts another flow of this workspace, optionally waiting for its result.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
target_flow_id | flow id | yes | Must be a flow of this workspace. |
mode | wait / fire_and_forget | no | |
input_vars | array of {name, value} | no | |
output_mapping | array of {from, to} | no | wait mode: copy the child's variables back. |
result_variable | variable name | no |
Branches: wait: completed / default / failed / timeout. fire_and_forget: default / failed.
tag
Updates the contact: tags, fields, subscriptions and languages.
| Field | Type / allowed values | Required | Notes |
|---|---|---|---|
add_tags | array of tag names | no | Tags are matched by NAME, case-insensitively. |
remove_tags | array of tag names | no | |
set_fields | array of {field, value} | no | field is a built-in (birthday / company / email / first_name / language / last_name / lifecycle_stage / phone / source) or custom.<key>. value may use variables. |
subscriptions | array of {list_id, channel_type, action} | no | Subscribe or unsubscribe the contact. |
languages_add | array of language codes | no | |
languages_remove | array of language codes | no |
Branches: default.
Condition rules
Used by condition and by every case of branch.
{"field": "contact.tag", "operator": "has_tag", "value": "VIP"}
{"field": "vars.api_result.body.count", "operator": "gt", "value": "10", "value_type": "number"}
field is one of:
- a curated field:
contact.has_method/contact.tag/flow.entry_at/flow.last_button_clicked/flow.last_channel_used/flow.last_inbound/subscription, - a dotted field under
contact./flow.var.(for examplecontact.first_name,contact.custom.plan,flow.var.answer), - a variable path whose first part is
contact/conversation/flow/message/trigger/vars(for examplevars.api_result.body.count).secretsis never allowed in a condition.
An unknown namespace is rejected with a path such as nodes[route].cases[0].rules[0].field.
operator is one of after / before / contains / contains_any / ends_with / eq / equals / exists / gt / gte / has_contact_method_for_channel / has_tag / is_empty / is_false / is_not_empty / is_subscribed_to_list / is_true / language_identified / language_in / language_is / language_not / language_not_in / language_unidentified / link_clicked / lt / lte / more_than_days_ago / neq / not_contains / not_equals / not_exists / not_has_contact_method_for_channel / not_has_tag / not_one_of / not_subscribed_to_list / on / one_of / starts_with / within_last_days.
Operators without a value: exists / has_contact_method_for_channel / is_empty / is_false / is_not_empty / is_subscribed_to_list / is_true / language_identified / language_unidentified / link_clicked / not_exists / not_has_contact_method_for_channel / not_subscribed_to_list.
| Operator | Only valid on field |
|---|---|
link_clicked | flow.var.<name> |
has_contact_method_for_channel | contact.has_method |
is_subscribed_to_list | subscription |
language_identified | contact.language |
language_in | contact.language |
language_is | contact.language |
language_not | contact.language |
language_not_in | contact.language |
language_unidentified | contact.language |
not_has_contact_method_for_channel | contact.has_method |
not_subscribed_to_list | subscription |
Tag rules (has_tag, not_has_tag) compare the tag NAME, case-insensitively.
one_of, not_one_of and contains_any take a JSON array of strings as value.
Language operators (language_identified / language_in / language_is / language_not / language_not_in / language_unidentified) take ISO codes and accept include_unidentified: true.
value_type (optional) is one of auto / boolean / date / number / string. With an explicit type, the operator must fit it and a literal value must parse as that type:
value_type | Operators |
|---|---|
auto | after / before / contains / contains_any / ends_with / eq / exists / gt / gte / is_empty / is_false / is_not_empty / is_true / lt / lte / more_than_days_ago / neq / not_contains / not_exists / not_one_of / on / one_of / starts_with / within_last_days |
string | contains / contains_any / ends_with / eq / exists / gt / gte / is_empty / is_not_empty / lt / lte / neq / not_contains / not_exists / not_one_of / one_of / starts_with |
number | eq / exists / gt / gte / is_empty / is_not_empty / lt / lte / neq / not_exists |
boolean | eq / exists / is_false / is_true / neq / not_exists |
date | after / before / eq / exists / gt / gte / is_empty / is_not_empty / lt / lte / more_than_days_ago / neq / not_exists / on / within_last_days |
Variables
Text fields may contain variables written as {{namespace.field}}. Paths use dots, [0] for list items and ["key"] for keys with special characters.
| Namespace | Contents |
|---|---|
contact | The contact: first_name, last_name, email, phone, custom.<key>, tags, owner fields. |
message | The message that started the run (text, type). |
conversation | The current conversation, including assigned_agent. |
trigger | The trigger payload (for example {{trigger.order_id}} for an API event). |
vars | Flow variables written by steps (output_var, target_field: flow.var.<key>, ...). flow.var.<key> is an alias. |
flow | Run facts: last_inbound, last_button_clicked, last_channel_used, entry_at. |
secrets | Workspace secrets. Only in the api_call node; never in messages or conditions. |
Unknown variables render as an empty string. Variable names you create cannot be is_test / stop_flow_url / test_conversation_id / waflow and cannot start with __.
Edges and branches
An edge is {from, to, branch}. from and to are node ids. branch selects which exit of the source node the edge belongs to; each node type lists its labels above. Most action nodes use default. At publish every reachable node must be connected and every condition needs both yes and no.
Triggers
trigger is {type, config}. Allowed types:
| Type | Config |
|---|---|
api_event | Started by your API call with a payload, read as {{trigger.<key>}}. |
click_to_ad | First message of a conversation that started from an ad. channels subset of instagram / messenger / whatsapp, scope any_ad / specific_ads. |
comment_received | A comment on an Instagram or Facebook post. match_mode contains / exact / starts_with, scope all_posts / specific_posts. |
contact.created | A contact was created. Optional field filters. |
contact.subscribed | A contact subscribed to a list. |
contact.unsubscribed | A contact unsubscribed from a list. |
contact.updated | A contact's fields, tags or subscriptions changed. Optional filters. |
contact_tag_added | A tag was added to a contact. tag (name) or tag_id. |
contact_tag_removed | A tag was removed from a contact. tag (name) or tag_id. |
conversation_assigned | A conversation's assignee changed. assignee_scope, user_ids, inbox_ids, assignment_sources. |
conversation_closed | A conversation was closed. Optional channel_ids, tag_filter. |
conversation_opened | A conversation was opened. Optional channel_ids, tag_filter. |
incoming_message | Every inbound message. Optional channel_ids, tag_filter {any_of: [...]}, regex. |
keyword_match | Inbound message matching keywords[] with match_mode contains / exact / regex, optional channel_ids. |
manual | Started from the app or POST /flows/{flow_id}/runs. No config. |
schedule | Time based. schedule_kind: birthday / recurring_cron (with time_of_day_local / recurrence_rule). |
whatsapp_flow.completed | A contact submitted a WhatsApp Flow form. |
Any event trigger may also carry a business-hours gate config.schedule with mode inside / outside and weekly windows keyed by fri / mon / sat / sun / thu / tue / wed.
IDs from your workspace
Channel, team member, inbox, list, tag, bot, template and flow ids must belong to the workspace that owns the flow. An id from another workspace (or one that was deleted) is reported as a validation problem on save and blocks publishing. Tags in add_tags, remove_tags and tag rules are names, so they work across workspaces.
Validation errors
When a definition is invalid, POST /flows, PATCH /flows/{flow_id} and POST /flows/{flow_id}/publish return HTTP 422:
{
"detail": {
"code": "flow_definition_invalid",
"message": "The flow has 1 problem: nodes[route].cases[0].rules[0].field 'foo.bar' uses unknown namespace 'foo'. ...",
"errors": [
{
"path": "nodes[route].cases[0].rules[0].field",
"message": "nodes[route].cases[0].rules[0].field 'foo.bar' uses unknown namespace 'foo'. ..."
}
]
}
}
errors[].path names the node by its id. Graph-level problems use an empty path or an edges[N], trigger or entry_node_id path.
A draft saves as long as its structure is valid. A published or paused flow is live: a PATCH that changes its definition or default_channel_chain must pass every publish check, otherwise it is refused with reason: "live_flow_invalid" and nothing is saved. To keep such an edit, save it as a draft copy with POST /api/v1/flows/{flow_id}/duplicate and the body {"definition": ...}.
Keep node ids stable when you edit a live flow: contacts waiting on a step whose id is no longer in the saved definition leave the flow (their run is canceled with flow_redefined). Preview how many with POST /api/v1/flows/{flow_id}/edit-impact and the body {"definition": ...}.
Complete example
{
"schema_version": 1,
"trigger": {
"type": "keyword_match",
"config": {
"keywords": [
"pricing"
],
"match_mode": "contains"
}
},
"entry_node_id": "welcome",
"nodes": [
{
"id": "welcome",
"type": "send",
"display_name": "Welcome",
"config": {
"messages": [
{
"type": "text",
"order": 0,
"delay_seconds": 0,
"text": "Hi {{contact.first_name}}, happy to help with pricing."
}
]
}
},
{
"id": "ask_size",
"type": "choice",
"display_name": "Ask team size",
"config": {
"prompt": {
"messages": [
{
"type": "text",
"order": 0,
"delay_seconds": 0,
"text": "How big is your team?"
}
]
},
"options": [
{
"id": "small",
"label": "1 to 10"
},
{
"id": "large",
"label": "More than 10"
}
],
"attempt_timeout_seconds": 86400
}
},
{
"id": "route",
"type": "branch",
"display_name": "Route by tag",
"config": {
"cases": [
{
"label": "VIP",
"rules_op": "all",
"rules": [
{
"field": "contact.tag",
"operator": "has_tag",
"value": "VIP"
}
]
}
]
}
},
{
"id": "has_email",
"type": "condition",
"display_name": "Has email?",
"config": {
"rules": [
{
"field": "contact.email",
"operator": "is_not_empty"
}
]
}
},
{
"id": "tag_lead",
"type": "tag",
"display_name": "Tag as lead",
"config": {
"add_tags": [
"Lead"
]
}
},
{
"id": "handover",
"type": "open_conversation",
"display_name": "Hand over to sales",
"config": {
"assign_strategy": "none",
"add_conversation_tags": [
"Sales"
]
}
},
{
"id": "small_reply",
"type": "send",
"display_name": "Self-serve answer",
"config": {
"messages": [
{
"type": "text",
"order": 0,
"delay_seconds": 0,
"text": "Our Basic plan fits most small teams."
}
]
}
},
{
"id": "done",
"type": "end_flow",
"display_name": "Done",
"config": {}
}
],
"edges": [
{
"from": "welcome",
"to": "ask_size",
"branch": "default"
},
{
"from": "ask_size",
"to": "small_reply",
"branch": "small"
},
{
"from": "ask_size",
"to": "route",
"branch": "large"
},
{
"from": "ask_size",
"to": "done",
"branch": "fallback"
},
{
"from": "ask_size",
"to": "done",
"branch": "timeout"
},
{
"from": "route",
"to": "handover",
"branch": "case:0"
},
{
"from": "route",
"to": "has_email",
"branch": "else"
},
{
"from": "has_email",
"to": "tag_lead",
"branch": "yes"
},
{
"from": "has_email",
"to": "handover",
"branch": "no"
},
{
"from": "tag_lead",
"to": "handover",
"branch": "default"
},
{
"from": "small_reply",
"to": "done",
"branch": "default"
},
{
"from": "handover",
"to": "done",
"branch": "default"
}
]
}
Prompt for your AI
Paste this together with the page above into your AI assistant, then describe the flow you want.
You write SendSeven flow definitions as JSON.
Follow the SendSeven flow format spec exactly:
- Output ONE JSON object with keys schema_version (1), trigger, entry_node_id, nodes, edges.
- Every node is {"id", "type", "config"} plus an optional "display_name" (unique).
"position" ({"x", "y"}) is optional; you may omit it.
- Use only the node types, fields and branch labels listed in the spec. Never invent keys.
- entry_node_id is the first action node (never the trigger).
- Every edge is {"from", "to", "branch"}. Use the exact branch labels of the source node.
- Variables are {{namespace.field}}, e.g. {{contact.first_name}} or {{vars.order.total}}.
- Never invent ids for channels, team members, inboxes, lists, bots, templates or flows.
Leave the field out (or ask me) unless I gave you a real id.
- Tags in add_tags, remove_tags and has_tag rules are tag NAMES, not ids.
- Put API credentials in {{secrets.NAME}}, never inline.
Return only the JSON, no commentary.