Triggers and the API
Every flow has exactly one trigger — the trigger object in its definition. You pick and configure it in the Trigger step of the flow-creation wizard and can change or fine-tune it anytime on the canvas.
| Trigger kind | When it fires |
|---|---|
manual | Started explicitly — via POST /flows/{id}/runs (the most common integration pattern), from the Scheduler, or by a team member clicking "Start Flow" on a contact's page. |
incoming_message | Any inbound message from a contact (optionally restricted to specific channels). |
keyword_match | An inbound message matches configured keywords — exact, contains, or regex match modes, with optional case sensitivity. |
conversation_opened | A new conversation is opened on any channel. |
conversation_closed | A conversation transitions to closed — by an agent, a flow, or an auto-close rule. |
conversation_assigned | A conversation gets a new assignee (agent, API, automation or another flow). Never fires on unassign or on re-assigning the same user. See below. |
contact_tag_added | A specific tag is added to a contact. |
contact_tag_removed | A specific tag is removed from a contact. Deleting the tag itself does not fire it. |
comment_received | A comment on one of your Instagram or Facebook Page posts matches the configured filters (requires picking the account to watch). The flow's first message goes out as a private reply — see Comments to Private Replies. |
click_to_ad | The first inbound message of an ad-originated conversation (WhatsApp CTWA, Messenger CTM, Instagram click-to-ad), optionally narrowed to specific ads. |
schedule | Cron-style or birthday-anchored. Runs evaluate every minute via the in-pod rule evaluator. |
whatsapp_flow.completed | A contact submits a WhatsApp Flow (form). See below. |
api_event | Internal platform events (e.g. contact.created) start the flow. |
Message triggers. The payload of incoming_message, keyword_match and click_to_ad includes the message that started the run: {{trigger.message_id}}, {{trigger.message_type}} (the stored message type in lower case, for example text, audio or image) and {{trigger.is_voice}} (true for a recorded voice note). They stay fixed for the whole run; {{message.type}} and {{message.is_voice}} follow the latest reply instead (see Variables).
Both tag triggers name their tag in the trigger config: tag_id (wins when set) or tag (the tag's name, compared trimmed and case-insensitive). A tag trigger without either never fires.
Triggers that react to contact changes (contact_tag_added, contact_tag_removed, contact created / updated, subscribed / unsubscribed) are covered by loop protection.
This guide focuses on the manual API path — the way external systems integrate.
Conversation lifecycle triggers
Flows can subscribe to two conversation events:
| Trigger | When it fires |
|---|---|
conversation_opened | A new conversation is opened (any channel, including widget live chat, WhatsApp first-message, etc.). |
conversation_closed | A conversation transitions to the closed state — whether by an agent, a flow's Open Conversation follow-up, or an auto-close rule. Useful for NPS surveys, auto-tagging closed tickets, and follow-up sequences. |
Both share the same config surface:
- Channel allow-list — restrict the trigger to specific channel IDs (e.g., only fire on your DE WhatsApp).
- Contact filters — only fire when the contact matches a set of tag / list / custom-field rules.
Configure these on the flow's Trigger tab.
Conversation assigned trigger
conversation_assigned starts a run each time a conversation's assignee changes to a user. The run is linked to that conversation. Config keys (siblings of kind on the trigger, all optional):
| Key | Type | Meaning |
|---|---|---|
assignee_scope | "any" | "specific_users" | "inbox_members" | Whose assignment fires the flow. Default "any". |
user_ids | string[] | Required and non-empty when assignee_scope is specific_users. |
inbox_ids | string[] | Required and non-empty when assignee_scope is inbox_members: fires when the new assignee is a member of one of these inboxes. |
assignment_sources | string[] | Subset of manual, api, automation, flow. Empty or missing means all. |
channel_ids | string[] | null | Channel allow-list. null means all channels. |
tag_filter | {"any_of": [tag names]} | null | Contact must carry at least one of these tags. |
schedule | object | Business-hours gate, same as the other event triggers. |
Unknown keys are rejected when the flow is saved. Publishing fails if the chosen scope has an empty user_ids / inbox_ids list.
The trigger payload is available in templates as {{trigger.assigned_user_id}}, {{trigger.assigned_user_name}}, {{trigger.assigned_user_email}}, {{trigger.previous_assigned_user_id}}, {{trigger.assigned_by_user_id}}, {{trigger.assignment_source}} and {{trigger.conversation_id}}.
Loop protection: a flow never starts itself through its own assignment, flow-to-flow assignment chains stop after three hops, and nothing fires unless the assignee changes. Every assignment that can start this trigger also emits the conversation.assigned webhook.
WhatsApp Flow completed trigger
whatsapp_flow.completed starts a run each time a contact submits a WhatsApp Flow (a native WhatsApp form), no matter how it was sent: from the inbox, a campaign, the API or an automation. The run is linked to the contact and, when there is one, the conversation. Config keys (siblings of kind on the trigger, all optional):
| Key | Type | Meaning |
|---|---|---|
definition_ids | string[] | Only these WhatsApp Flows. Empty or missing means any. |
channel_ids | string[] | null | Channel allow-list. null means all WhatsApp channels. |
include_automation_sessions | boolean | Default false. See below. |
schedule | object | Business-hours gate, same as the other event triggers. |
Unknown keys are rejected when the flow is saved, and a trigger with an unknown key never fires.
No double handling. A WhatsApp Flow sent by a Send WhatsApp Flow node whose run is still waiting is answered by that run, which continues on its completed branch. This trigger skips such submissions unless it sets include_automation_sessions: true. If the waiting run has already timed out or ended, the trigger fires as usual.
The trigger payload is available in templates:
| Token | Value |
|---|---|
{{trigger.whatsapp_flow.answers.<name>}} | An answer, by input name |
{{trigger.whatsapp_flow.definition_id}}, {{trigger.whatsapp_flow.definition_name}} | The WhatsApp Flow |
{{trigger.whatsapp_flow.session_id}}, {{trigger.whatsapp_flow.version_id}} | The session and the flow version |
{{trigger.whatsapp_flow.sender_type}} | inbox, campaign, automation, api or adopted |
{{trigger.whatsapp_flow.completed_at}} | ISO-8601 time of the submission |
{{trigger.channel_id}}, {{trigger.conversation_id}} | Where the answer arrived |
Each session starts at most one run per flow, even if WhatsApp delivers the answer twice. Mapped answers are already written to the contact when the run starts, so {{contact.email}} shows the new value. The same submission is also sent as the whatsapp_flow.completed webhook.
Endpoints
POST /api/v1/flows # create
POST /api/v1/flows/validate # check a definition without saving
GET /api/v1/flows # list
GET /api/v1/flows/{id} # read
PATCH /api/v1/flows/{id} # update (send version; read-only fields ignored)
POST /api/v1/flows/{id}/publish # draft → published
POST /api/v1/flows/{id}/pause # published → paused
DELETE /api/v1/flows/{id} # archive
POST /api/v1/flows/{id}/test-run # start a test run (nothing reaches customers)
POST /api/v1/flows/{id}/test-run/{run_id}/inbound # answer the flow as the test contact
GET /api/v1/flows/{id}/test-run/{run_id}/trace # executed steps of the test run
POST /api/v1/flows/{id}/test-run/{run_id}/restart # end and start again
DELETE /api/v1/flows/{id}/test-run/{run_id} # end your test runs of this flow
GET /api/v1/flows/{id}/revisions # version history
GET /api/v1/flows/{id}/revisions/{revision_id} # one version with its definition
GET /api/v1/flows/{id}/revisions/{revision_id}/diff # compare with current / previous / another version
POST /api/v1/flows/{id}/revisions/{revision_id}/restore # restore a version (new save)
GET /api/v1/flows/{id}/edit-lease # who is editing in the builder
POST /api/v1/flows/{id}/edit-lease # acquire / heartbeat (also DELETE, /take-over, /request)
GET /api/v1/flows/{id}/loop-pauses # contacts paused by loop protection
POST /api/v1/flows/{id}/loop-pauses/{contact_id}/resume # resume one contact
POST /api/v1/flows/{id}/loop-pauses/resume-all # resume every paused contact
POST /api/v1/flows/{id}/resume-auto-pause # undo a whole-flow loop pause
POST /api/v1/flows/{id}/runs # start a run for a contact
GET /api/v1/flows/{id}/runs # list runs (filter by contact_id, status)
GET /api/v1/flows/{id}/runs/{run_id} # run detail with step audit
POST /api/v1/flows/{id}/runs/{run_id}/stop # cancel an in-flight run
GET /api/v1/flows/{id}/runs/{run_id}/replay # timeline of one run (steps, branches, variables, messages)
GET /api/v1/flows/{id}/observability/funnel # contacts per step
GET /api/v1/flows/{id}/observability/channels # delivery and reply rates per channel
GET /api/v1/flows/{id}/observability/steps # per-step stats and connection traffic
GET /api/v1/flows/{id}/observability/steps/{node_id}/failures # recent failures of one step
GET /api/v1/flows/{id}/observability/attention # failed and stuck steps, most frequent first
GET /api/v1/flows/variables?flow_id={uuid} # variables available inside a flow
GET /api/v1/flows/secrets # list flow secrets (names + masked previews)
PUT /api/v1/flows/secrets/{NAME} # create or rotate a secret
DELETE /api/v1/flows/secrets/{NAME} # delete a secret
Variables endpoint
GET /api/v1/flows/variables?flow_id={uuid} returns the typed list of values the Branch-node variable picker (and other condition UIs) can reference:
{
"contact_fields": [
{"path": "contact.first_name", "key": "contact.first_name", "label": "First name", "type": "string"}
],
"custom_fields": [
{"path": "contact.custom.loyalty_tier", "key": "contact.custom.loyalty_tier", "label": "Loyalty tier", "type": "string"}
],
"tags": [{"path": "vip", "key": "vip", "label": "vip", "type": "string"}],
"lists": [{"path": "<list uuid>", "key": "<list uuid>", "label": "Newsletter", "type": "string"}],
"flow_variables": [
{"path": "flow.vars.captured_email", "key": "flow.vars.captured_email", "label": "captured_email", "type": "string"}
]
}
Every entry carries path and key with the same value (the reference you use in a condition or template), plus a display label and a best-effort type.
custom_fields is filled only when your API key also has custom_fields:read. tags is filled only when it has contacts:read or conversations:read. Without them those sections are empty lists; the other sections are unchanged.
With a flow_id, flow_variables also lists the built-in entries message.text, message.type (string), message.is_voice (boolean) and message.channel_type, plus trigger and conversation.id. The other flow_variables are sourced from the flow definition itself — specifically, the output_var_name field on collect_input nodes, output_var on kb_query nodes, output_var_name on create_tracked_link nodes, output_var on api_call nodes (plus its .status / .body sub-paths) and declared_outputs / error_var on code nodes. This means the picker only ever surfaces variables that actually exist downstream of a given node. The full template grammar is documented in Variables.
Secrets endpoints
Flow secrets back the {{secrets.NAME}} tokens of the API Call node. Values are write-only.
PUT /api/v1/flows/secrets/STRIPE_KEY
Content-Type: application/json
{"value": "sk_live_..."}
{"name": "STRIPE_KEY", "preview": "sk_…8f", "updated_at": "2026-09-01T10:12:44Z"}
GET /api/v1/flows/secrets returns {"items": [{"name", "preview", "updated_at"}], "pagination": {...}}; DELETE /api/v1/flows/secrets/{NAME} returns {"success": true, "id": "..."}. Names must match ^[A-Z][A-Z0-9_]{0,63}$ (lower-case input is upper-cased), values are 1–4096 characters. Listing needs flows:read, writing and deleting need flows:write.
Publishing a flow that references a secret which does not exist fails with 422 flow_definition_invalid and an error such as nodes[n3].config: references unknown secret {{secrets.STRIPE_KEY}} — add it under Flows → Secrets.
All endpoints require a Bearer API token with the flows:* scopes shown in the Overview.
Starting a run
The flow must be published and accept manual starts: flows with the manual trigger always do, others only with manually_triggerable: true (otherwise 400 Flow is not enabled for manual triggering). Scope flows:write.
The simplest case — start a flow for a known contact:
curl -X POST "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "08621525-6836-4578-bf43-4145d8b2097e"
}'
Response (201, or 200 when an idempotency key returned an existing run):
{
"id": "488a51ac-80a5-41a2-b5f5-425846394201",
"flow_id": "5b5a7f31-aeb1-4cfb-b747-b698303833fe",
"contact_id": "08621525-6836-4578-bf43-4145d8b2097e",
"status": "running",
"current_node_id": "node_welcome",
"started_at": "2026-04-29T10:44:09.234567Z"
}
The first node executes synchronously inside the request handler when possible (Send and Tag nodes are non-blocking). Long-running nodes (Delay, Collect Input, Condition waits) immediately return a running or waiting response and continue in the background.
| Field | Required | Description |
|---|---|---|
contact_id | Yes | The contact the run is for |
context | No | Flat object of values for the run, read as {{vars.<key>}}. Up to 16 KB |
idempotency_key | No | See Idempotency. The Idempotency-Key header works too |
channel_id | No | Start the run "on" this channel: Send steps that prefer the originating channel use it, and "current channel" entries of Tag / Update Contact nodes refer to it. Must be an active channel of your workspace |
contact_method_id | No | Start with this contact method of the contact (for example one of several phone numbers). With channel_id it must be able to reach that channel; it is then used as the destination on that channel |
contact_method_overrides | No | {"<channel type>": "<contact method id>"}: which of the contact's methods to use per channel instead of the primary one |
conversation_id | No | The contact's conversation the run was started from. Only marks the origin (the conversation shows the run); it does not bind the run to it |
Invalid channel_id, contact_method_id or overrides return 400 with a message naming the problem.
Passing data into the run
Use context to hand values to the run; nodes reference them as {{vars.<key>}}:
curl -X POST "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "08621525-6836-4578-bf43-4145d8b2097e",
"context": {
"order_id": "ORD-12345",
"order_total": "49.99",
"support_link": "https://example.com/support/ORD-12345"
}
}'
Inside the flow's nodes, {{vars.order_id}} resolves to ORD-12345. Keys the engine reserves for itself (such as waflow) are dropped. Unknown top-level request fields are ignored, so a misspelled context silently sends no data.
Idempotency
Pass idempotency_key to make repeated POSTs safe — duplicate keys return the existing run instead of creating a new one:
{
"contact_id": "08621525-6836-4578-bf43-4145d8b2097e",
"idempotency_key": "order-confirmation:ORD-12345"
}
This is critical when wiring flows to your own webhook handlers (Stripe, your CRM, etc.) — retries from those systems would otherwise spawn duplicate runs.
Re-entry rules
If the contact already has an active run on this flow, the engine applies the flow's re_entry_policy:
block— the request returns409 Conflictwith{"detail": {"error": "...", "code": "FLOW_RUN_BLOCKED"}}. No new run is created.restart— the existing run is canceled, a new run starts. Returns201 Createdwith the new run ID.parallel— both runs execute side by side. Returns201 Createdfor the new run.
Test runs
A test run plays a flow (draft or published) for you without reaching customers. It uses your workspace's live chat channel and a shared test contact. Send steps on other channels, API calls, tag changes and similar side effects are simulated and marked simulated: true in the trace. Conversational Agent steps run for real inside the test conversation. Every test-run endpoint requires flows:write and a plan with Flows.
Start a run:
curl -X POST https://api.sendseven.com/api/v1/flows/FLOW_ID/test-run \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"trigger_type": "incoming_message", "trigger_text": "Hi"}'
| Field | Notes |
|---|---|
trigger_type | Required: incoming_message (with trigger_text), conversation_opened, conversation_closed, manual or a contact trigger type. |
trigger_text | The first message of the test contact, for incoming_message. |
simulate_channel_id | Optional channel id: Send steps render that channel's content (for example the WhatsApp version). An id from another workspace is ignored. |
simulate_language | Optional language code to test a multi-language flow. |
The response is {run_id, session_id, conversation_id, contact_id, ws_url, simulate_channel_id}. Starting a new test run ends your previous one for the same flow.
Then:
POST /flows/{id}/test-run/{run_id}/inboundwith{"text": "...", "attachment_ids": [...]}answers the flow as the test contact. At least one of the two is required (422test_inbound_empty); up to 5 attachments fromPOST /attachments/upload(an id from another workspace:422test_inbound_attachment_invalid).GET /flows/{id}/test-run/{run_id}/tracereturns{"steps": [...]}, the executed steps in order with their inputs, outputs andsimulatedflag. Read the flow's replies from the testconversation_idwith the conversation messages API.POST /flows/{id}/test-run/{run_id}/restart(same body as start) ends the run and starts a new one.DELETE /flows/{id}/test-run/{run_id}ends your test runs of this flow.
Listing and filtering runs
# All runs for a flow
curl "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs?status=running&limit=50" \
-H "Authorization: Bearer $API_TOKEN"
# Runs for a specific contact
curl "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs?contact_id=$CONTACT_ID" \
-H "Authorization: Bearer $API_TOKEN"
Each run includes the audit trail when fetched by ID:
curl "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs/$RUN_ID" \
-H "Authorization: Bearer $API_TOKEN"
The response includes steps[] — every node execution with started_at, finished_at, status, output_snapshot, and error_text (if any). Timestamps are full microsecond ISO strings.
Stopping a run
curl -X POST "https://api.sendseven.com/api/v1/flows/$FLOW_ID/runs/$RUN_ID/stop" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "customer-requested-cancellation" }'
The run transitions to canceled and any active waits are cleaned up. The response is the updated run.
Cancel reasons
A canceled run carries the reason in context.canceled_reason. Common values:
| Reason | Meaning |
|---|---|
contact_unsubscribed | The contact unsubscribed after the run started and the flow's exit_on_unsubscribe setting matched. context.cancel_details holds list_id, channel_type and source (list_id and channel_type can be null). |
stop_keyword | The contact sent an opt-out keyword and the flow has allow_stop_keyword enabled. |
agent_took_over | An agent replied in the conversation and stop_on_agent_reply is on. |
conversation_closed | The run's conversation was closed and stop_on_conversation_closed is on. |
parent_ended | The parent run of a Start Flow child (wait mode) ended. |
flow_redefined | The flow was saved without the step the run was waiting on (see Editing a live flow). |
A run stopped through this endpoint gets a reason that starts with stopped.
Tracking runs (no flow webhooks)
Flow runs do not emit customer webhook events: flow.run.* events are not in the list of events you can subscribe to in the webhooks setup, and creating a subscription for them is rejected. To follow a run, poll the runs endpoints:
GET /api/v1/flows/{id}/runs/{run_id}returns the run'sstatus(running,waiting,completed,failed,canceled),completed_atand the step audit.GET /api/v1/flows/{id}/runs?contact_id=...&status=...lists runs for a contact.
The messages a flow sends still produce the normal message webhooks (message.sent, delivery statuses, message.received for replies).
A run started by a Start Flow node in another flow carries its lineage in context.subflow: parent_run_id is the run that started it and root_run_id the top-level run of the chain. Runs started any other way have no context.subflow.
Loop protection
A flow that changes the contact (adds a tag, sets a field, subscribes) can start itself again through its own contact trigger, and so can an integration that writes back to the contact on every run. Loop protection stops this:
- When a flow starts 10 times within 10 minutes for the same contact through a contact trigger, the flow is paused for that contact. Other contacts are not affected. Runs already in progress continue.
- When this happens to 3 different contacts within an hour and the changes came from automations, integrations, the API, imports, bulk actions or team members, the whole flow is paused (
status: "paused"). - Message, keyword, button, schedule and conversation triggers are never counted, so a chatty customer cannot pause a bot. Runs you start with
POST /flows/{id}/runsand Start Flow nodes are neither counted nor blocked. - Workspace owners and admins receive an email when it happens.
A flow paused by loop protection carries auto_paused_at, auto_pause_reason (loop_detected) and auto_pause_details (the contacts involved and the thresholds); a manually paused flow has auto_paused_at: null. Every flow also has active_loop_pause_count, the number of contacts it is currently paused for.
| Endpoint | Scope | Description |
|---|---|---|
GET /flows/{id}/loop-pauses?status=active|resumed|all | flows:read | The paused contacts, newest first, paginated (page_size up to 100). Each item: contact_id, contact_name (only with contacts:read too), status, start_count, window_seconds, pause_count, last_trigger_type, last_origin, paused_at, resumed_at, resumed_by_user_id |
POST /flows/{id}/loop-pauses/{contact_id}/resume | flows:write | Let the flow run for this contact again and reset its counter. 404 (loop_pause_not_found) if the flow was never paused for the contact |
POST /flows/{id}/loop-pauses/resume-all | flows:write | Resume every paused contact: {"success": true, "resumed": 4}. Does not change the flow's own status |
POST /flows/{id}/resume-auto-pause | flows:publish | Publish a flow paused by loop protection again. Body {"resume_contacts": true} also resumes every paused contact. A manually paused flow returns 409 (flow_not_auto_paused). Runs the same checks as publishing first; an invalid flow returns 422 (reason: "live_flow_invalid") and stays paused |
Fix the cause first (for example a Tag node that re-adds the trigger tag), or the loop will pause the flow again.
Version history, restore, PATCH with version and the edit lease are covered in Version history and editing.
Error responses
Errors carry a detail field: a message string, or an object with a code for the cases listed below. See Error Handling for all response shapes.
| Status | detail | Meaning |
|---|---|---|
422 | {"code": "flow_definition_invalid", "message", "errors": [{"path", "message"}]} | The flow's definition is invalid (unknown node type, orphan nodes, missing edge branches, unknown variable, unknown secrets, an id from another workspace, ...). Returned by POST /flows, PATCH /flows/{flow_id}, POST /publish, POST /resume-auto-pause and POST /duplicate. See Validation errors. |
400 | {"code": "FLOW_DEFINITION_INVALID", "errors": [...]} | The published definition fails validation when starting a run. |
400 | "Flow is not enabled for manual triggering" | The flow has no manual trigger and manually_triggerable is off. |
400 | message | Invalid channel_id, contact_method_id or contact_method_overrides. |
404 | "Flow ... not found." | The flow doesn't exist or belongs to another workspace. |
404 | "Contact ... not found." | The contact ID doesn't exist in your workspace. |
409 | {"code": "FLOW_RUN_BLOCKED"} | Re-entry policy is block and an active run exists. |
409 | "Flow ... is in status 'paused'; only published flows can be started." | Runs can only start on published flows. |
422 | list of field errors | Invalid request body, for example context larger than 16 KB. |
Validation errors
Every validation failure on POST /api/v1/flows, PATCH /api/v1/flows/{flow_id} and POST /api/v1/flows/{flow_id}/publish returns 422 with one shape:
{
"detail": {
"code": "flow_definition_invalid",
"message": "The flow has 1 problem: nodes[route_1].cases[0].rules[0].field 'foo.bar' uses unknown namespace 'foo'. ...",
"errors": [
{
"path": "nodes[route_1].cases[0].rules[0].field",
"message": "nodes[route_1].cases[0].rules[0].field 'foo.bar' uses unknown namespace 'foo'. ..."
}
],
"error": "The flow has 1 problem: ...",
"legacy_code": "FLOW_DEFINITION_INVALID"
}
}
errors[].pathnames the node by itsid(nodes[<node_id>].<field>), never by its position innodes[]. Graph-level problems use an empty path, or anedges[N],trigger,entry_node_idordefault_channel_chainpath.- Some errors carry a stable
codeyou can branch on.entry_node_is_trigger: the stored flow still uses a legacy trigger node as its entry. Save the definition again and it is normalized (the trigger node is removed,entry_node_idmoves to its target). messageis a one-line summary naming the first problem; show it when you cannot map a path.reasonis set for special cases:channel_chain_required(a flow that sends messages needs a non-email default channel),exit_settings_invalid(exit-on-unsubscribe list ids) andrequest_invalid(the request body itself, for exampledefinitionis not an object), andlive_flow_invalid(an edit to a published or paused flow failed the publish checks, see below).- Node
positionis optional; a missing position is never an error. - Channel, team member, inbox, list, tag, bot, WhatsApp template and flow ids must belong to your workspace. On save, an unknown id shows up in the flow's
validation.errors(andcan_activateisfalse); publishing refuses it with the422above.
Check a definition without saving
POST /api/v1/flows/validate runs the save checks and writes nothing. Use it to check a definition while you build it (for example from an AI client) before you create or update the flow. Requires flows:read or flows:write.
The body can be the exact POST /api/v1/flows body; only these fields are read, the others are ignored:
| Field | Notes |
|---|---|
definition | Required. Same format and 256 KB cap as a save. |
default_channel_chain | Optional. Without flow_id, it is treated like POST /flows (left out = a default chain is added; an empty list for a flow that sends messages = reason: channel_chain_required). |
flow_id | Optional. Check as a PATCH of this flow: the stored chain is used unless you send one, and if the flow is published or paused the publish checks run too (reason: live_flow_invalid). Unknown id: 404. |
A definition that a save would refuse returns the same 422 as a save (above). Otherwise the response is 200:
{
"valid": true,
"is_live": false,
"default_channel_chain": [{"channel_type": "whatsapp"}],
"validation": {"can_activate": false, "errors": [{"path": "nodes[send_1].config.messages", "message": "..."}], "warnings": []}
}
validation has the same shape as validation on a flow. A valid draft can still have can_activate: false: validation.errors lists what to fix before you publish.
Warnings
Problems that don't block saving are returned in the flow's validation.warnings as {path, message, code}. code is a stable warning kind, for example whatsapp_window_template_fallback for WhatsApp send steps that may fire more than 24 hours after the contact's last message without an approved template fallback. The flow format spec lists every code and whether it can be ignored.
To hide a kind of warning for one flow, list its code in definition.ignored_warning_codes:
{
"definition": {
"ignored_warning_codes": ["whatsapp_window_template_fallback"],
"nodes": []
}
}
Those warnings are left out of validation.warnings, and validation.ignored_warnings_count says how many were hidden. Unknown codes and codes that can't be ignored are removed when you save. Errors are never hidden. The list never changes how the flow runs: a WhatsApp message sent outside the window without a template still fails.
Editing a live flow
A draft saves even when it is not ready to publish. A published or paused flow is live, so a PATCH /api/v1/flows/{flow_id} that changes its definition or default_channel_chain must pass the full publish checks (the same ones as POST /publish, including the workspace-id checks above). If it fails, the response is the 422 above with "reason": "live_flow_invalid", and nothing is saved: the flow keeps its previous version. A PATCH that only changes other fields (for example name) is not checked this way. POST /api/v1/flows/{flow_id}/resume-auto-pause runs the same checks.
To keep a refused edit, save it as a new draft copy:
curl -X POST https://api.sendseven.com/api/v1/flows/FLOW_ID/duplicate \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Welcome flow (copy)", "definition": { "...": "your edited definition" } }'
The copy is always a draft (201), takes its other settings from the source flow, and only has to pass the save checks. Its node ids are regenerated. Requires flows:write.
What happens to contacts already in the flow
Contacts who enter the flow after the save get the new version. Contacts already in it continue on the version they started with, with two exceptions:
- Removed steps. If the new definition no longer contains the step a run is currently waiting on (the step was deleted, or deleted and re-created, which gives it a new id), that run is canceled when you save. Its
context.canceled_reasonisflow_redefined. - Exit and stop settings (
stop_on_agent_reply,stop_on_conversation_closed,exit_on_unsubscribe) andagent_while_runningapply immediately to every run of the flow.
A Start Flow step always starts the target flow's current published version.
To show the impact before saving, post the definition you are about to save:
curl -X POST https://api.sendseven.com/api/v1/flows/FLOW_ID/edit-impact \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "definition": { "...": "your edited definition" } }'
{
"affected_runs": 3,
"by_node": [
{ "node_id": "ask_size", "label": "Ask for size", "count": 3 }
]
}
affected_runsis the number of running or waiting runs that the save would cancel;by_nodelists them per removed step, with the step's name from the currently saved definition (labelisnullwhen the step has no name).- Read-only: nothing is saved, and the definition is not validated (an incomplete definition still gets a preview). The body has the same 256 KB cap as a save.
- A draft or archived flow always returns
{"affected_runs": 0, "by_node": []}. An unknown flow returns404. - Requires
flows:write(the same scope as the save).
Changed in November 2026: these errors were 400 with detail.code FLOW_DEFINITION_INVALID (or FLOW_CHANNEL_CHAIN_REQUIRED, FLOW_EXIT_SETTINGS_INVALID). The status is now 422 and the code flow_definition_invalid. The old code is still sent as legacy_code, and the old detail.error string as error, so clients that read those keys keep working; clients that branch on the status code must accept 422.
The full definition format is in the Flow format spec.