> ## 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.

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

> Keep a flow definition under version control, validate and simulate it in CI, and publish it through the Flows API as part of your infrastructure pipeline — the developer-side workflow for non-IVR journeys and automation flows.

# 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](/guides/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:

```bash cURL theme={null}
# Composition-level export: flow + referenced agents (portable across tenancies)
curl -X GET "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/export" \
  -H "X-API-Key: dv_live_sk_..." \
  -o flows/welcome-lead.export.json
```

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:

```bash cURL theme={null}
curl -X GET "https://api.orbit.devotel.io/api/v1/flows/flw_abc123" \
  -H "X-API-Key: dv_live_sk_..." | jq '.definition' > flows/welcome-lead.definition.json
```

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.

```bash cURL theme={null}
curl -X GET "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/validate" \
  -H "X-API-Key: dv_live_sk_..."
```

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:

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/simulate" \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "definition": '"$(cat flows/welcome-lead.definition.json)"',
    "audience_size": 1000,
    "default_country": "US"
  }'
```

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:

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/publish" \
  -H "X-API-Key: dv_live_sk_..."
```

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.

```yaml theme={null}
# .github/workflows/flows.yml
name: Flows

on:
  pull_request:
    paths: ["flows/**"]
  push:
    branches: [main]

env:
  FLOW_API_BASE: https://api.orbit.devotel.io/api/v1/flows
  FLOW_ID_PROD: flw_welcome_lead_prod
  FLOW_ID_STAGING: flw_welcome_lead_staging

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint flow JSON
        run: |
          jq -e '.nodes and (.nodes | length > 0)' \
            flows/welcome-lead.definition.json

      - name: Push to staging and server-validate
        env:
          ORBIT_API_KEY: ${{ secrets.ORBIT_API_KEY }}
        run: |
          # Repo definition -> throwaway staging flow
          jq -n --argjson def "$(cat flows/welcome-lead.definition.json)" \
            '{ definition: $def }' \
          | curl -sf -X PUT "$FLOW_API_BASE/$FLOW_ID_STAGING" \
              -H "X-API-Key: $ORBIT_API_KEY" \
              -H "Content-Type: application/json" \
              -d @-

          # Server-side structural validation
          curl -sf "$FLOW_API_BASE/$FLOW_ID_STAGING/validate" \
            -H "X-API-Key: $ORBIT_API_KEY" | tee validate.json
          jq -e '.valid == true and (.errors | length == 0)' validate.json

      - name: Simulate with the repo definition
        env:
          ORBIT_API_KEY: ${{ secrets.ORBIT_API_KEY }}
        run: |
          jq -n --argjson def "$(cat flows/welcome-lead.definition.json)" \
            '{ definition: $def, audience_size: 1000, default_country: "US" }' \
          | curl -sf -X POST "$FLOW_API_BASE/$FLOW_ID_STAGING/simulate" \
              -H "X-API-Key: $ORBIT_API_KEY" \
              -H "Content-Type: application/json" \
              -d @- \
          | jq '.'

  publish:
    if: github.ref == 'refs/heads/main'
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Push and publish to production
        env:
          ORBIT_API_KEY: ${{ secrets.ORBIT_API_KEY }}
        run: |
          # GET-first for a read-your-own-writes sanity check
          before=$(curl -sf "$FLOW_API_BASE/$FLOW_ID_PROD" \
            -H "X-API-Key: $ORBIT_API_KEY")
          echo "Prod flow is at version $(jq -r .version <<<"$before")"

          # Push the repo definition
          jq -n --argjson def "$(cat flows/welcome-lead.definition.json)" \
            '{ definition: $def }' \
          | curl -sf -X PUT "$FLOW_API_BASE/$FLOW_ID_PROD" \
              -H "X-API-Key: $ORBIT_API_KEY" \
              -H "Content-Type: application/json" \
              -d @-

          # Atomic publish — snapshot + status flip in one transaction
          curl -sf -X POST "$FLOW_API_BASE/$FLOW_ID_PROD/publish" \
            -H "X-API-Key: $ORBIT_API_KEY"
```

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.

```bash cURL theme={null}
# See the ledger
curl -X GET "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/versions" \
  -H "X-API-Key: dv_live_sk_..."

# Roll the current draft back to a prior snapshot
curl -X POST \
  "https://api.orbit.devotel.io/api/v1/flows/flw_abc123/versions/<version_id>/restore" \
  -H "X-API-Key: dv_live_sk_..."
```

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.