Flow execution model
A flow is a static definition: nodes, edges, and one trigger. A flow execution is one live run of that definition — a row that is created when a trigger fires, advanced node by node, parked on long waits, and finalized with a terminal status. This page explains that runtime as a state machine, the same way delivery lifecycle explains outbound messages and voice call lifecycle explains calls. Read it to understand what an execution can do, when it stops being cheap, and where its state is visible.Definition vs execution
Two separate records, two separate lifecycles:- The flow definition is the graph you author — the
definition.nodes/definition.edgesJSON plus its saved snapshots. Creating or editing a flow never runs it. - The execution is one traversal of that graph against one trigger payload. Each trigger fire creates exactly one execution row (
exec_…), which holds the trigger data, the per-node step trace, and the run-level status. Two executions of the same flow are independent — they share no state.
The trigger catalog
Every execution starts from exactly one trigger firing. The fivetrigger_type values partition into two shapes:
Whatever the trigger supplies becomes the execution’s initial context — the seed for all variable resolution the run does. The full per-trigger payload matrix is in the Flows trigger catalog.
Node semantics
An execution walks the graph node by node. Four node families behave differently at runtime:
Two invariants govern node advancement:
- Edge routing is by handle, not by drawing order. A
conditionnode hasyesandnohandles; anabTestnode has one handle per variant; any node can carry an expliciterrorhandle. When no handle matches, the executor falls back to the node’s unlabeled default edge; when that is absent too, the run ends. The per-node handle choice lands in the step trace, so a branch that went the wrong way is inspectable per node. - An unrecognized node type is a no-op, not a failure. The executor logs the node as unhandled and takes its default edge — a node does nothing unless its
typeis one the executor implements.
Execution states
The run-level status is a short state machine:
Two points about how states change:
waiting → runningis automatic. There is no call you make to resume a parked execution — the webhook-worker flow scheduler pollsflow_executionsevery 30 seconds, findswaitingrows whose delay step’sresumeAthas elapsed, and POSTs an internal resume. You observe the transition through the status flip, not through any API call.- A run has no
cancelledstate. Execution rows only ever finalize tocompleted,completed_with_errors,failed, ortimeout— unlike a message, which you can cancel between queued and sent. To stop a flow you unpublish the flow itself (POST /flows/:id/unpublish), which rejects new executions; in-flight executions keep finishing.
Async hand-off: how a wait survives a restart
The interesting property of the runtime is that a long wait does not hold a process open. When an execution reaches adelay (or sto_wait) node whose wait exceeds the inline budget (60 seconds), the executor:
- Writes a step with
status: "waiting"and aresumeAttimestamp (for adayswait in a timezone-aware flow, resolved by calendar arithmetic so the resume lands on the same wall-clock hour across DST). - Persists the whole run — steps, context, status
waiting— intoflow_executionsand returns, freeing the worker.
waiting rows (an in_flight claim update under FOR UPDATE SKIP LOCKED, capped to a bounded batch per cycle so two scheduler replicas can never double-resume one row), and POSTs POST /flows/executions/:id/resume over the internal API. The resume path re-reads the persisted step trace and trigger data, reconstructs the context, and continues the traversal from the node after the wait. A stale row left in in_flight by a pod that died mid-resume is re-flipped to waiting by the same scheduler’s stale-row reaper after a five-minute window, so a crash costs at most one scheduler tick of recovery — the run is never stranded.
The consequence for you: a flow that says “wait 24 hours, then send the reminder” costs nothing during the 24 hours. The run exists only as a row, and the resume is indistinguishable from a fresh start except that the step trace and context carry over. This is the queue backbone described in async processing model applied to flow pauses.
Variables and context
Data flows through an execution as a context — the mutable key/value map the run resolves{{...}} placeholders against before every node’s config is used. The context is built by layer, in this order:
- Trigger data — whatever the trigger supplied (for an inbound message:
message,from,to,channel,message_id). - Contact fields — when the run carries a contact, its fields are available as flat keys (
{{first_name}}, not{{contact.first_name}}). - Node outputs — action and AI nodes add their results under internal
_flow_*keys (for example awebhooknode’s HTTP status), which downstream conditions and functions can read. functionoutputs — afunctionnode’s derived variables land back in the context for all downstream nodes.
- Scoping is per execution. Context lives on the execution row, and a
splitbranch receives an isolated copy of the context — additions inside one parallel branch never leak into its sibling branches, and nothing leaks between executions. - A missing key resolves to an empty string, not an error — a placeholder whose key is absent renders empty, so a condition on an unset variable is a legitimate branch.
- Internal keys are prefixed. Anything the executor writes itself rides the
_flow_namespace, so a trigger payload can’t shadow runtime state (and a trigger-supplied_flow_key is overwritten by the runtime).
Versioning and publishing
A flow moves through three states:draft → published → (optionally back to) draft via unpublish. The critical property for executions is how a run binds to a graph:
- Only published flows can execute. Starting a draft flow returns an error; the dashboard Run action against a draft is blocked the same way. The gate is the flow row’s
status, checked before any step runs. - Publishing snapshots the graph atomically.
POST /flows/:id/publishwrites an immutable snapshot intoflow_versions(the publish transaction covers both the snapshot insert and thestatusflip, so there is no half-published state) and marks that snapshotis_published. The published snapshot is the graph the live executor reads — you can edit a published flow’s draft while its current version serves traffic, and the next publish swaps the live graph atomically.GET /flows/:id/versionslists the history;POST /flows/:id/versions/:versionId/restorepromotes an older snapshot back to being the current draft without touching the live version. - The resume re-reads the current published graph. When a run parks on a long wait, the resume path re-loads the flow’s published definition at resume time — so unpublishing (or re-publishing a different graph) while a run is parked changes what the resume continues with. Treat a flow with parked 24-hour executions like a running API schema: breaking the graph mid-wait fails-or-misroutes those resumes.
Where executions surface
An execution is observable from three angles:- The step trace — every node that ran records a step: node id, node type, per-step status (
pending,running,completed,failed,skipped), duration, and when applicable input/output/error. Flow Executions is the API surface for it. flow.*webhook events —flow.execution.started,flow.execution.completed, andflow.execution.failedfire on the run’s lifecycle transitions. Subscribe and consume per the webhook events reference.- Run-level fields —
status,steps_completed,total_steps,duration_ms, and the surfacederroron terminal failures, all queryable viaGET /flows/executionswith astatusfilter.
Common pitfalls
- Branching on display names instead of node ids. Two nodes with the same label still resolve edges by node id and handle. Inspect a mis-routed branch by its
node_idin the step trace, not by its rendered label. - Treating
completed_with_errorsas green. A node that failed but routed via anerroredge still records a failed step and a run error; the run-level status sayscompleted_with_errors. Alarm oncompleted,completed_with_errors, andfailedseparately. - Assuming a cancel exists. There is no cancel-execution endpoint; unpublish the flow to stop new executions, knowing the in-flight ones finish on their own.
- Editing a flow with long waits while executions are parked on it. A 24-hour delay creates a 24-hour window where the resume re-evaluates the flow’s current published definition. Changing the graph during that window affects which nodes the resume walks — see Versioning and publishing.
- Expecting
waitingto need a kick. A parked execution resumes by itself on the 30-second scheduler tick; there is no call you make to restart it, and it never needs one after a worker restart.