> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# How flow executions run: states, branching, async waits, and webhook hand-off

> The runtime model behind Orbit Flows — what an execution is versus a definition, how nodes advance, how long waits survive restarts, how branching state is held, how failures finalize, and how executions bind to a published version.

# 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](/concepts/delivery-lifecycle) explains outbound messages and [voice call lifecycle](/concepts/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.edges` JSON 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.

A flow graph is bulk-cloned metadata; an execution is durable working state. Everything below describes the execution's lifecycle, not the definition's.

## The trigger catalog

Every execution starts from exactly one trigger firing. The five `trigger_type` values partition into two shapes:

| Trigger type | What fires it                                                                                                | Initial execution context                                                                    |
| ------------ | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `event`      | An inbound message, a campaign send, or a platform event (for example `contact.created`) matched to the flow | The event payload — for an inbound message: `message`, `from`, `to`, `channel`, `message_id` |
| `webhook`    | An external system POSTs JSON to the flow's webhook URL                                                      | Whatever the caller POSTed                                                                   |
| `schedule`   | A cron schedule (`cron_expr`, optional `cron_tz`)                                                            | The scheduled fire time and the list the schedule iterates                                   |
| `manual`     | The Start Flow API or the dashboard **Run** action                                                           | Whatever the caller passed as trigger data                                                   |
| `workflow`   | Another flow starts this one                                                                                 | The parent flow hands off its context                                                        |

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](/flows/overview#trigger-catalog).

## Node semantics

An execution walks the graph node by node. Four node families behave differently at runtime:

| Node family        | Type ids                                                                                                                                                                                  | Runtime behavior                                                                                                                                                | What it writes to the context                                                                           |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **Action**         | `sendSms`, `sendWhatsapp`, `sendEmail`, `sendRcs`, `sendViber`, `sendLine`, `sendTelegram`, `sendInstagram`, `sendMessenger`, `sendPush`, `sendFax`, `makeCall`, `webhook` (HTTP request) | Dispatches the channel send or the outbound HTTP call, then advances on the default edge                                                                        | The response lands under internal `_flow_result_*` keys, so a later node can branch on the API's status |
| **Condition / AI** | `condition`, `aiClassify`, `aiResponse`, `aiSentiment`                                                                                                                                    | Evaluates a predicate against the context and picks the outgoing edge by **handle** (`yes`/`no`, an intent name, `positive`/`negative`/`neutral`)               | AI nodes add the classification/score so downstream nodes can use it                                    |
| **Time**           | `delay`, `sto_wait`                                                                                                                                                                       | Either blocks the run briefly inline or parks the whole execution as `waiting` (see [Async hand-off](#async-hand-off-how-a-wait-survives-a-restart))            | Nothing — the wait itself is schedule state, not data                                                   |
| **Logic & data**   | `split`, `merge`, `abTest`, `function`, `updateRecord`, `sendSurvey`                                                                                                                      | `split` forks the run down parallel branches (with a `waitMode`), `merge` joins them, `function` derives variables, `updateRecord` writes fields to the contact | New variables, updated contact fields, the A/B variant assignment                                       |

Two invariants govern node advancement:

1. **Edge routing is by handle, not by drawing order.** A `condition` node has `yes` and `no` handles; an `abTest` node has one handle per variant; any node can carry an explicit `error` handle. 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.
2. **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 `type` is one the executor implements.

## Execution states

The run-level status is a short state machine:

| Status                  | Meaning                                                                                                                                                                | Terminal? |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| `running`               | A worker has picked the execution up and is advancing nodes                                                                                                            | No        |
| `waiting`               | The run is parked on a long wait and will resume automatically when its `resumeAt` elapses                                                                             | No        |
| `completed`             | All reachable nodes finished                                                                                                                                           | Yes       |
| `completed_with_errors` | The run finished, but at least one node failed and was routed via its error edge (or fell through to the default edge). The last failure is in the run's `error` field | Yes       |
| `failed`                | A node threw an error with no error edge to route on, and the run was abandoned                                                                                        | Yes       |
| `timeout`               | The per-resume wall-clock cap (300 s) was exceeded mid-run                                                                                                             | Yes       |

Two points about how states change:

* **`waiting → running` is automatic.** There is no call you make to resume a parked execution — the webhook-worker flow scheduler polls `flow_executions` every 30 seconds, finds `waiting` rows whose delay step's `resumeAt` has elapsed, and POSTs an internal resume. You observe the transition through the status flip, not through any API call.
* **A run has no `cancelled` state.** Execution rows only ever finalize to `completed`, `completed_with_errors`, `failed`, or `timeout` — unlike a message, which you can cancel between [queued and sent](/concepts/delivery-lifecycle). 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 a `delay` (or `sto_wait`) node whose wait exceeds the inline budget (60 seconds), the executor:

1. Writes a step with `status: "waiting"` and a `resumeAt` timestamp (for a `days` wait in a timezone-aware flow, resolved by calendar arithmetic so the resume lands on the same wall-clock hour across DST).
2. Persists the whole run — steps, context, status `waiting` — into `flow_executions` and **returns**, freeing the worker.

The process that started the run is now irrelevant to it. The webhook-worker flow scheduler ticks every 30 seconds, atomically claims due `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](/concepts/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:

1. **Trigger data** — whatever the trigger supplied (for an inbound message: `message`, `from`, `to`, `channel`, `message_id`).
2. **Contact fields** — when the run carries a contact, its fields are available as flat keys (`{{first_name}}`, not `{{contact.first_name}}`).
3. **Node outputs** — action and AI nodes add their results under internal `_flow_*` keys (for example a `webhook` node's HTTP status), which downstream conditions and functions can read.
4. **`function` outputs** — a `function` node's derived variables land back in the context for all downstream nodes.

Three scoping rules:

* **Scoping is per execution.** Context lives on the execution row, and a `split` branch 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/publish` writes an immutable snapshot into `flow_versions` (the publish transaction covers both the snapshot insert and the `status` flip, so there is no half-published state) and marks that snapshot `is_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/versions` lists the history; `POST /flows/:id/versions/:versionId/restore` promotes 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:

1. **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](/flows/executions) is the API surface for it.
2. **`flow.*` webhook events** — `flow.execution.started`, `flow.execution.completed`, and `flow.execution.failed` fire on the run's lifecycle transitions. Subscribe and consume per the [webhook events reference](/webhooks/events).
3. **Run-level fields** — `status`, `steps_completed`, `total_steps`, `duration_ms`, and the surfaced `error` on terminal failures, all queryable via `GET /flows/executions` with a `status` filter.

## Common pitfalls

1. **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_id` in the step trace, not by its rendered label.
2. **Treating `completed_with_errors` as green.** A node that failed but routed via an `error` edge still records a failed step and a run error; the run-level status says `completed_with_errors`. Alarm on `completed`, `completed_with_errors`, and `failed` separately.
3. **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.
4. **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](#versioning-and-publishing).
5. **Expecting `waiting` to 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.

The per-step payload schema and the query/filter parameters for reading these records are in [Flow Executions](/flows/executions); the authoring side (triggers, nodes, edges) is in [Flows](/flows/overview) and [Flow Builder](/flows/builder).
