Skip to main content

Flows as code: author flow JSON in your repo, publish in CI

A flow graph is JSON. Anything JSON can live in Git, get reviewed in a pull request, and ship through CI. This guide walks the full loop: export a definition from Orbit into your repository, validate and simulate it in a pipeline, and publish it through the API — with the failure modes you will actually hit along the way. This is the right choice when several engineers touch the same flow, when you want a record of who changed what, or when a flow change must ride the same review and rollback process as the rest of your infrastructure. The visual canvas stays the better surface for sketching, one-off edits, and exploratory work — see IVR visual builder for the canvas-centric IVR path.

Publish from the canvas or publish from the repo

Pick one place of truth per flow. The dashboard canvas and your repository both hold the same definition document (the React-Flow graph of nodes and edges), so the question is which copy wins. Here is what “two-way” actually means in Orbit: there is no diff-merger. You can move the definition in both directions — export from the dashboard down to a file, import a file over the top — but when you push, you push the whole document. PUT /flows/:id is a full-definition replacement; the API does not merge node-by-node. Whoever writes last wins, and the write is all-or-nothing. Practical defaults:
  • Do one-off editing in the canvas, then export immediately (section below) so the repo never drifts.
  • Treat the repo as the source of truth for anything you deploy repeatedly — staging flows, per-branch smoke flows, anything versioned next to your app.
  • If a flow exists only in the dashboard and is never exported, that is a draft, not an asset. Export before you rely on it.

The four flows

Everything in this pattern is four API calls around the same definition JSON. Read them in order — each one leans on the previous.

1. Canvas → repo (export)

Pull the current definition out of the dashboard into a checked-in file. Two ways, depending on how much you want:
cURL
The export is a self-describing document (kind: orbit.flow.v1) that carries the flow definition plus any agent nodes’ configuration, so it can cross tenants. To get only the definition for a same-tenant round trip, pass ?include_agents=false, or fetch the flow row directly and save its definition field:
cURL
Keep the two files distinct in your head: the export is what you use to rehydrate a flow in a fresh tenant; the bare definition is what you PUT back to the flow you already own. Most repos check in the export for portability and derive the definition at deploy time.

2. Repo → validate (write, then GET /flows/:id/validate)

Server-side validation runs against the definition stored on the flow row, so pushing your repo JSON into Orbit is a prerequisite. POST your definition with PUT /flows/:id, then ask Orbit to check it. GET /flows/:id/validate returns { "valid": boolean, "errors": string[] } and checks that at least one node exists, that a trigger node is present, that every node is wired in (no disconnected nodes once there is more than one), that every node carries an id and type, that A/B-test variant weights sum to 100, and — using Tarjan’s algorithm — that no cycle contains only instant nodes with no wait point (those burn the step budget at runtime). It is read-only: it never mutates the flow.
cURL
Because validation is read-only and cheap, run it twice: once against a staging copy in CI, and once as a pre-flight after the production PUT and before publish. A broken graph fails here instead of at runtime.

3. Repo → simulate (POST /flows/:id/simulate)

Validation proves the graph is well-formed; it does not tell you what the graph will do. POST /flows/:id/simulate dry-runs a definition against an audience size and projects the per-step drop-off funnel, channel mix, predicted send cost, and a conversion estimate. It enrols no contact, writes no row, and sends nothing. Pass the repo-copy definition in the body to simulate it before you push it to production:
cURL
Optional knobs let you price against a real list (list_id) and tune assumptions like send_retention_rate, delay_retention_rate, branch_retention_rate, expected_conversion_rate, and avg_call_minutes. In CI, a post-validate simulate is the step that catches “we accidentally made the reminder wait a minute instead of a day” before customers do.

4. Repo → publish (POST /flows/:id/publish)

Publish is the lifecycle flip. Once the repo definition has been written with PUT (step 2 above), POST /flows/:id/publish locks the current stored definition as the live version:
cURL
Keep PUT and publish separate intentionally. The PUT carries your CI-checked JSON verbatim; the publish call then snapshots that exact definition into the immutable version ledger and flips status to published atomically — both writes in one transaction, so an aborted publish never leaves a half-updated ledger.

About optimistic concurrency

The Flows API does not gate PUT or publish on an If-Match / ETag header. What it does expose is a monotonic version integer on the flow row, and the publish ledger is transactional — concurrent publishes serialise on the version table’s unique constraint instead of silently overwriting each other. The CI-safe pattern, given the surface as it is:
  1. GET /flows/:id and record version (N) and updated_at.
  2. PUT /flows/:id with your repo definition. The server returns the updated row — check that version is now N+1 (or N+2 if the row was published, since a definition change on a published flow creates a draft bump) and that updated_at moved. If either is unexpected, someone else wrote in between; stop and re-read.
  3. POST /flows/:id/publish. The ledger write is atomic — if a concurrent publish lands first, yours serialises and wins by timestamp order, with each publish preserved as its own immutable snapshot. The conflict-then-retry dance the next section describes is therefore the exceptional path, not the norm.
Do not invent an If-Match flow-publish contract the API does not expose; the server-side transaction plus your GET-before-PUT discipline covers the optimistic-concurrency case today.

CI/CD: a GitHub Actions example

A minimal pipeline has two jobs. On a pull request, push the repo definition to a throwaway staging flow and validate + simulate it there. On merge to main, push and publish to the named production flow.
Name the staging flow after the branch (e.g. welcome-lead-pr-123) and tear it down on merge so a stale staging flow never goes live by accident.

Handling publish conflicts

Two writers pushing at once produce one of two failure shapes:
  • A 409 on publish. The POST /flows/:id/publish endpoint rejects a publish when the flow is already published or in another non-publishable state — the lifecycle gate returns 409, it does not silently ack a no-op. Treat that as a signal your local view is stale.
  • A silent overwrite. PUT is a full-definition replacement. If a teammate shipped between your GET and your PUT, your write replaces their draft — the publish ledger preserves their snapshot, but the current draft row is now yours. There is no row-level equality check on PUT today, so the guard is your own discipline (always GET immediately before PUT, never blind-retry a 409).
The recovery in both cases is the same three steps:
  1. Re-read — GET /flows/:id, capture the fresh version and the current definition.
  2. Re-apply — if the repo is source of truth, the re-apply is just writing your checked-in definition back over the top, but only after confirming the drift is expected (a teammate publish, a dashboard tweak you want to keep). If someone edited the canvas, decide explicitly whether the repo or the canvas wins — do not let an automatic retry make that call.
  3. Re-publish — PUT the definition, then POST .../publish again.
Never retry-the-same-write blindly on a 409: replay without re-read just replays yesterday’s loss.

Reading publish history and rolling back

Every publish appends an immutable entry to the version ledger. GET /flows/:id/versions returns each snapshot with its id, monotonic version_number, the captured definition, a diff_summary, created_by, the is_published flag, and created_at. Use it to answer “what did version 7 actually look like” without guessing.
cURL
Restore rewrites the flow’s current draft definition to the chosen snapshot; it does not touch the published status. On a published flow this keeps the live publish active — you must re-run POST .../publish after restore for the rollback to take effect in production. That separation is deliberate: you can always stage a rollback, inspect it, and only then cut it live.

Failure modes and the canvas/DSL gap

Plan CI around the failures you will actually meet:
  • Invalid graph rejected at validate. Structural issues surface in the errors list before publish: disconnected nodes, a missing trigger, an un-waited cycle. Fix these in the repo and re-run the validate step; do not push past them hoping publish will succeed anyway.
  • A/B-test variant weights don’t sum to 100. Caught by the validator; common when a branch is edited by hand and a weight typo slips in.
  • Schedule trigger without cron_expr. trigger_type = "schedule" requires a valid cron string; the API rejects the write if it is missing or unparseable.
  • Long delays defer to the scheduler. A delay node under about a minute runs inline; anything longer hands off to the background flow scheduler that resumes the execution when the wait expires. Do not read a waiting status on an execution row as a failure — it is the scheduler deferral. If your simulated flow pauses where it should not, check the per-step resumeAt on the execution before assuming a stuck run.
  • Audience size cap on simulate. The modelled audience is capped (well past anything a real projection needs); keep audience_size realistic or the request is rejected.
  • Status-transition rejections with 409. Illegal lifecycle moves (publishing an already-published archived flow, unpublishing a draft, restoring as a no-op) return 409/422 instead of silently succeeding. Treat that as a stale-state signal, not a retry prompt.

Canvas ⇄ DSL parity

The canvas and the JSON DSL are the same graph, so in principle every node round-trips. In practice the two surfaces are not perfectly symmetrical, and knowing where they diverge saves a confused afternoon:
  • Executor is the ground truth. The DSL accepts any type the runtime executor has a case for — the send family (sendSms, sendWhatsapp, sendEmail, sendRcs, sendViber, sendLine, sendTelegram, sendInstagram, sendMessenger, sendPush, sendFax), voice (makeCall), AI (aiAgent, aiClassify, aiResponse, aiSentiment), CRM (updateContact, updateRecord), survey (sendSurvey), experimentation (abTest), timing (delay, sto_wait), and the structural nodes (trigger, condition, function, split, merge, transform, experiment).
  • Canvas may lag on rarely-used channels. A JSON-authored flow can carry a node type the canvas has no palette entry for yet; the canvas will still render those nodes as read-only blocks. Round-tripping that flow through the canvas does not re-add what was not there — it preserves the unknown nodes as opaque geometry.
  • The reverse is not true. Every node type the canvas exposes is one the executor handles; the canvas palette never produces a node the runtime cannot execute. If a JSON change fails in the canvas but validates server-side, the node type is real — the canvas just has not learned to draw it yet.
  • Trigger types are the full DSL surface. The row-level trigger_type enum (manual, webhook, schedule, event, workflow) is what the API accepts; the canvas UI surfaces a subset as one-click starters. DSL-authored schedules and webhook triggers are fully supported even when the canvas does not show them as tiles.
When in doubt, let the validate response be the arbiter of what a graph is allowed to contain, and keep the canvas for visual review rather than round-trip editing of DSL-only graphs.