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 samedefinition 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 samedefinition 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
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
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
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
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
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 gatePUT 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:
GET /flows/:idand recordversion(N) andupdated_at.PUT /flows/:idwith your repo definition. The server returns the updated row — check thatversionis now N+1 (or N+2 if the row was published, since a definition change on a published flow creates a draft bump) and thatupdated_atmoved. If either is unexpected, someone else wrote in between; stop and re-read.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.
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 tomain, push and publish to the named production flow.
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
409on publish. ThePOST /flows/:id/publishendpoint rejects a publish when the flow is alreadypublishedor in another non-publishable state — the lifecycle gate returns409, it does not silently ack a no-op. Treat that as a signal your local view is stale. - A silent overwrite.
PUTis a full-definition replacement. If a teammate shipped between yourGETand yourPUT, 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 onPUTtoday, so the guard is your own discipline (alwaysGETimmediately beforePUT, never blind-retry a409).
- Re-read —
GET /flows/:id, capture the freshversionand the currentdefinition. - Re-apply — if the repo is source of truth, the re-apply is just writing your checked-in
definitionback 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. - Re-publish —
PUTthe definition, thenPOST .../publishagain.
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
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 theerrorslist 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
delaynode 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 awaitingstatus on an execution row as a failure — it is the scheduler deferral. If your simulated flow pauses where it should not, check the per-stepresumeAton 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_sizerealistic 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) return409/422instead 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
typethe 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_typeenum (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.
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.