Skip to main content

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 kindWhen it fires
manualStarted 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_messageAny inbound message from a contact (optionally restricted to specific channels).
keyword_matchAn inbound message matches configured keywords — exact, contains, or regex match modes, with optional case sensitivity.
conversation_openedA new conversation is opened on any channel.
conversation_closedA conversation transitions to closed — by an agent, a flow, or an auto-close rule.
conversation_assignedA 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_addedA specific tag is added to a contact.
contact_tag_removedA specific tag is removed from a contact. Deleting the tag itself does not fire it.
comment_receivedA 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_adThe first inbound message of an ad-originated conversation (WhatsApp CTWA, Messenger CTM, Instagram click-to-ad), optionally narrowed to specific ads.
scheduleCron-style or birthday-anchored. Runs evaluate every minute via the in-pod rule evaluator.
whatsapp_flow.completedA contact submits a WhatsApp Flow (form). See below.
api_eventInternal 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:

TriggerWhen it fires
conversation_openedA new conversation is opened (any channel, including widget live chat, WhatsApp first-message, etc.).
conversation_closedA 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):

KeyTypeMeaning
assignee_scope"any" | "specific_users" | "inbox_members"Whose assignment fires the flow. Default "any".
user_idsstring[]Required and non-empty when assignee_scope is specific_users.
inbox_idsstring[]Required and non-empty when assignee_scope is inbox_members: fires when the new assignee is a member of one of these inboxes.
assignment_sourcesstring[]Subset of manual, api, automation, flow. Empty or missing means all.
channel_idsstring[] | nullChannel allow-list. null means all channels.
tag_filter{"any_of": [tag names]} | nullContact must carry at least one of these tags.
scheduleobjectBusiness-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):

KeyTypeMeaning
definition_idsstring[]Only these WhatsApp Flows. Empty or missing means any.
channel_idsstring[] | nullChannel allow-list. null means all WhatsApp channels.
include_automation_sessionsbooleanDefault false. See below.
scheduleobjectBusiness-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:

TokenValue
{{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.

FieldRequiredDescription
contact_idYesThe contact the run is for
contextNoFlat object of values for the run, read as {{vars.<key>}}. Up to 16 KB
idempotency_keyNoSee Idempotency. The Idempotency-Key header works too
channel_idNoStart 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_idNoStart 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_overridesNo{"<channel type>": "<contact method id>"}: which of the contact's methods to use per channel instead of the primary one
conversation_idNoThe 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 returns 409 Conflict with {"detail": {"error": "...", "code": "FLOW_RUN_BLOCKED"}}. No new run is created.
  • restart — the existing run is canceled, a new run starts. Returns 201 Created with the new run ID.
  • parallel — both runs execute side by side. Returns 201 Created for 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"}'
FieldNotes
trigger_typeRequired: incoming_message (with trigger_text), conversation_opened, conversation_closed, manual or a contact trigger type.
trigger_textThe first message of the test contact, for incoming_message.
simulate_channel_idOptional channel id: Send steps render that channel's content (for example the WhatsApp version). An id from another workspace is ignored.
simulate_languageOptional 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}/inbound with {"text": "...", "attachment_ids": [...]} answers the flow as the test contact. At least one of the two is required (422 test_inbound_empty); up to 5 attachments from POST /attachments/upload (an id from another workspace: 422 test_inbound_attachment_invalid).
  • GET /flows/{id}/test-run/{run_id}/trace returns {"steps": [...]}, the executed steps in order with their inputs, outputs and simulated flag. Read the flow's replies from the test conversation_id with 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:

ReasonMeaning
contact_unsubscribedThe 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_keywordThe contact sent an opt-out keyword and the flow has allow_stop_keyword enabled.
agent_took_overAn agent replied in the conversation and stop_on_agent_reply is on.
conversation_closedThe run's conversation was closed and stop_on_conversation_closed is on.
parent_endedThe parent run of a Start Flow child (wait mode) ended.
flow_redefinedThe 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's status (running, waiting, completed, failed, canceled), completed_at and 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}/runs and 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.

EndpointScopeDescription
GET /flows/{id}/loop-pauses?status=active|resumed|allflows:readThe 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}/resumeflows:writeLet 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-allflows:writeResume every paused contact: {"success": true, "resumed": 4}. Does not change the flow's own status
POST /flows/{id}/resume-auto-pauseflows:publishPublish 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.

StatusdetailMeaning
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.
400messageInvalid 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.
422list of field errorsInvalid 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[].path names the node by its id (nodes[<node_id>].<field>), never by its position in nodes[]. Graph-level problems use an empty path, or an edges[N], trigger, entry_node_id or default_channel_chain path.
  • Some errors carry a stable code you 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_id moves to its target).
  • message is a one-line summary naming the first problem; show it when you cannot map a path.
  • reason is 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) and request_invalid (the request body itself, for example definition is not an object), and live_flow_invalid (an edit to a published or paused flow failed the publish checks, see below).
  • Node position is 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 (and can_activate is false); publishing refuses it with the 422 above.

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:

FieldNotes
definitionRequired. Same format and 256 KB cap as a save.
default_channel_chainOptional. 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_idOptional. 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_reason is flow_redefined.
  • Exit and stop settings (stop_on_agent_reply, stop_on_conversation_closed, exit_on_unsubscribe) and agent_while_running apply 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_runs is the number of running or waiting runs that the save would cancel; by_node lists them per removed step, with the step's name from the currently saved definition (label is null when 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 returns 404.
  • 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.