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

# Build and ship your first IVR flow

> Author an IVR flow in the Orbit DSL or in the visual builder, validate it before publish, simulate a scripted caller, attach it to a DID, hand off to a human when needed, and roll back with version history.

An IVR flow is a `{ nodes, edges }` graph you submit to a single set of endpoints. Creation and every edit run the graph validator before persistence, publishing copies the draft into the live snapshot, and a simulator lets you drive a scripted caller through the graph before a DID ever rings. This guide walks the full loop end to end.

We will build a small airline-style flow: greet the caller, route booking or refund intents to an ACD queue, and fall back to voicemail when none match. Follow it step for step, then adapt the graph to your own branches.

## 1. IVR primitives

Flow definitions are executed by the voice runtime against the Jambonz verb pipeline. The graph validator — which runs on every create and update — checks exactly one entry node (type `ivrStart`), at least one terminal node (hangup, transfer, voicemail, ringGroup, dialByName, offerCallback, offerSmsCallback, scheduleCallback), that every edge references existing node ids, that every node is reachable from the entry, and that no reachable node is trapped in a cycle with no route to a terminal. The building blocks mirror the programmable voice DSL — see the [Programmable Voice DSL reference](/reference/programmable-voice-dsl) for the underlying `say` / `gather` / `dial` / `enqueue` / `transfer` / `hangup` verbs the runtime emits.

Speech intents supplement DTMF menus: the `speechInput` and `dialByName` node types match a free-form utterance against per-node `speechIntents` or per-tenant intent buckets. The [IVR intents concept page](/voice/ivr-intents) explains those buckets — including how a node-level speech menu goes fully DTMF-free; this guide shows how both drop into a runnable flow.

<Warning>
  Outbound termination is Devotel-only. A `transfer` node whose destination is a raw SIP URI is rejected by the validator with `transfer_external_sip` — use an E.164 phone number or an on-net extension.
</Warning>

## 2. Build the flow

Send the definition to `POST /api/v1/voice/ivr-flows`. The body accepts `name`, the `definition` graph, and an optional `active` flag. The validator runs before persistence, so a malformed graph returns `422 IVR_FLOW_GRAPH_INVALID` and nothing is stored.

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/voice/ivr-flows" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Airline inbound router",
    "definition": {
      "nodes": [
        { "id": "start", "type": "ivrStart" },
        { "id": "welcome", "type": "playAudio", "data": { "ttsText": "Welcome to Horizon Airlines." } },
        {
          "id": "menu",
          "type": "menu",
          "data": {
            "menuOptions": [
              { "key": "1", "label": "Bookings" },
              { "key": "2", "label": "Support agent" }
            ]
          }
        },
        { "id": "bookings", "type": "queue", "data": { "label": "Bookings queue" } },
        { "id": "support", "type": "queue", "data": { "label": "Support queue" } },
        { "id": "fallback", "type": "voicemail", "data": { "label": "Voicemail fallback" } },
        { "id": "end", "type": "hangup" }
      ],
      "edges": [
        { "source": "start", "target": "welcome" },
        { "source": "welcome", "target": "menu" },
        { "source": "menu", "target": "bookings", "sourceHandle": "1" },
        { "source": "menu", "target": "support", "sourceHandle": "2" },
        { "source": "menu", "target": "fallback", "sourceHandle": "default" },
        { "source": "fallback", "target": "end" }
      ]
    }
  }'
```

The response carries the new flow's `id` (an `ivr_*` identifier) — keep it; every later step uses it.

Menu nodes branch one edge per DTMF key present in `menuOptions`; the validator rejects empty, duplicate, or non-DTMF keys with `menu_empty_key`, `menu_duplicate_key`, or `menu_invalid_key`. Slot-filling nodes collect structured values from free-form speech — declare one `speechInput` node with prompts per entity, then hand each collected value to `POST /api/v1/voice/ivr-slots/extract`, a stateless endpoint that takes `utterance`, a `slots` spec (name, type such as `account_number` / `order_id` / `date` / `amount`, prompt, `required`, `enumValues`), and the values gathered so far. It returns the updated `filled`, the `next_prompt` for whatever is still missing, and a `complete` flag — thread the `filled` map across turns in your own session state. See the runtime's intent classification (the [IVR intents page](/voice/ivr-intents)) for the speech-only path.

## 3. Validate before you publish

Save-time graph validation already ran on `create`. Re-run it on every edit — `PUT /api/v1/voice/ivr-flows/:id` validates the supplied `definition` the same way before persisting, so a broken transition never reaches production. If the payload fails, the API returns `422 IVR_FLOW_GRAPH_INVALID` with `errors` entries keyed by code: shape problems (`no_entry`, `multiple_entries`, `no_exit`), dead links (`orphan_edge_source`, `orphan_edge_target`, `unreachable_node`), and loop traps (`cycle_without_exit`, `dead_end_node`) where a caller would hang until the platform reaps the call. A `menu_invalid_key` on this flow would mean, for example, that you typed `book` as a key and every caller lands on the fallback edge until you fix it. Program a CI step to PUT a scratch flow with your candidate definition and treat `422` as a pre-publish failure rather than allowing the build.

## 4. Test with the simulator

Drive a scripted caller through the graph before attaching it to a DID. `POST /api/v1/voice/ivr-flows/:id/simulate` consumes `turns` (each with `digits`, `speech`, and/or `intent`) and returns, for every step, the node id visited, the matching handle taken, the verb categories emitted, and the `termination_reason`. The walk replays the runtime's edge precedence — handle → `default` → single default edge — and pauses at interactive nodes (menu, dtmfInput, speechInput, dialByName, offerCallback, offerSmsCallback, scheduleCallback).

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/voice/ivr-flows/ivr_01h.../simulate" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "turns": [{ "digits": "1" }],
    "max_steps": 50
  }'
```

**200 OK** — expect `reached_nodes: ["start","welcome","menu","bookings"]`, `emitted_verbs` including `gather` at the menu and `enqueue` at the queue, and `termination_reason: "queue_answered"` (a queue with no mock overflow ends as answered). Run the fallback too — send `turns: []` emptied and the walk ends `awaiting_input` at the menu; send a `digits: "9"` press and the simulator follows your `default` edge into voicemail. A red simulation catches routing regressions before you burn a live minute, so treat it as the in-product equivalent of an IVR regression harness.

Useful options: `source: "published"` walks the live snapshot, `source: "draft"` (default) walks the unsaved draft; `definition` (full graph body) simulates an unsaved candidate inline.

## 5. Attach the flow to a DID

Each DID's inbound routing config carries the flow reference. Set `type: "ivr"` with `config.flowId`:

```bash cURL theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/numbers/+14155550123/routing" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "type": "ivr", "config": { "flowId": "ivr_01h..." } }'
```

The voice gateway resolves the flow on every inbound call, so the attach is immediate — publish before you attach and the first caller hits the live snapshot. To exercise the link end to end without phoning in, re-run the simulator against the same flow id; that is the combination of the two routes — the routing row determines which definition the walk uses, and the simulator projects exactly what the caller will hear.

## 6. Hand off to an agent

Route the `support` branch to an ACD queue via a `queue` node (the `enqueue` verb in DSL terms) so a team member picks up from the dashboard, or point the whole flow at an [AI agent](/voice/ai-agent-handback) whose existing handback endpoint keeps the conversation and escalates back to the AI after a human resolves the exception. Both paths are documented linked there — this flow's `queue` node is the half of the handoff that feeds a queue; the handback page covers the intent-loop return.

## 7. The visual builder — the same graph, without the JSON

Everything above is a `{ nodes, edges }` graph to your code, but operators usually work in the dashboard's IVR Builder — the first-class authoring surface where the same graph is a visual tree. A DTMF menu reads as the tree it produces: one node per option, one labelled edge per key. Drop a **Menu** node from the palette, and it already branches per key; connect each branch to a queue, ring group, voicemail, or another menu, and the canvas keeps the tree readable with Auto-layout. Drafts autosave as a draft (nothing a caller can reach), the Validate preflight flags unreachable branches, duplicate keys, and dead ends before publish, and the built-in simulator steps a scripted caller through the draft without burning a live minute. Publish flips the graph to the live snapshot — it is the same published-definition the API writes.

**Where to open it:** Voice → IVR Builder in the dashboard. Every node type in this guide is in the palette, and the same DID-attach is two clicks away from the builder: pick the flow on a number's inbound-routing tab (Numbers → *number* → Routing), or attach from the builder toolbar. After publish, open a node's analytics panel in the builder to see per-node funnel drop-off — the same data `node-analytics` returns — which usually tells you what to change next.

**Which surface do you pick?** The builder is the authoring tool; the API is the same graph for CI pipelines and infrastructure-as-code. The API-driven loop above is the full contract; the builder writes the identical object to the identical endpoints.

### Grammar editor in the builder panel

Select a **Speech Input** node and the right-hand panel becomes a full speech-grammar editor — the same per-node `speechIntents` object the API accepts, edited row by row:

* **Intent name** — lowercase letters, digits, hyphens, underscores. Each name becomes an edge handle on the node; wire it to the queue, AI agent, or sub-flow for that utterance. The panel flags duplicate and malformed names inline, and a name already wired to an edge is what the downstream edge references, so rename before you wire.
* **Phrases** — one per line. Every phrase is flattened into Deepgram recognition hints, and at call time the transcript is matched against them after normalisation (case, punctuation, word order tolerated). Matching is deterministic phrase-versus-transcript string math — no LLM round trip runs in the live gather.
* **Keypad digit (optional)** — a single DTMF digit per intent (e.g. `1`). The digit is matched before any speech, so a caller who can't be heard still gets through. It only appears while keypad fallback is on.
* **Keypad fallback toggle** — the on/off switch for digits. Set it off to make the menu speech-only: the gather accepts no digits, and an utterance nobody matches falls through the node's `nomatch` handle. Nodes saved before this control existed behave as if it were on, so a legacy speech menu keeps accepting digits until you turn it off.
* **STT provider** — Deepgram by default; pick Google, Microsoft, AWS, or the cluster default if the node needs a different recognizer.
* **Min confidence** — a 0–1 speech-to-text confidence gate. Blank keeps the vendor default; set it and a transcript recognised below the gate is rejected as a no-match, so a noisy line never lands the caller on a wrong intent.

Worked example — a two-intent speech menu, bookings with a keypad digit kept and support speech-only:

```json speechInput node theme={null}
{
  "id": "speech-menu",
  "type": "speechInput",
  "data": {
    "prompt": "Say what you need — for example, book a flight, or talk to support.",
    "language": "en-US",
    "dtmfFallbackEnabled": false,
    "confidenceThreshold": 0.6,
    "speechIntents": [
      {
        "name": "bookings",
        "phrases": ["book a flight", "new booking", "reserve a seat"],
        "dtmfKey": "1"
      },
      {
        "name": "support",
        "phrases": ["talk to an agent", "customer support", "help me"]
      }
    ]
  }
}
```

With the node body above, a caller saying "I'd like to book a flight" at transcript confidence 0.82 clears the 0.6 gate, `bookings` matches after normalisation, and the walk takes the `bookings` edge. The same utterance at confidence 0.41 is below the gate, so nothing matches and the walk takes the `nomatch` edge. Even with fallback off, digit `1` still routes to `bookings` — a digit always wins over speech — but with `dtmfFallbackEnabled: false` the gather collects no digits at all and every keyed entry above is pure speech. (For a truly digit-free variant, drop the `dtmfKey` too.)

The panel keeps a draft of each intent separately from the persisted graph: a saved node's stored phrases are read back into the per-line editor on load, and edits are written back as the wire shape on save, so reopening a saved Speech Input node never renders blank rows.

Per-utterance routing beyond keyword phrases (bucket descriptions, confidence thresholds across the whole tenant) lives in the [IVR intents concept page](/voice/ivr-intents) — see its DTMF-free speech menus section.

## 8. Version and roll back

Every publish appends a row to the flow's version history. `GET /api/v1/voice/ivr-flows/:id/versions` lists each published snapshot (`version`, `definition`, `published_at`); `GET /:id/versions/:version/compare` diffs a snapshot against your current draft and reports added, removed, and changed node ids so you can see exactly what an edit introduced. To roll back, `POST /:id/versions/:version/revert` restores that snapshot and re-publishes it as the new head — one call instead of fetching the old definition and re-attaching it. The attached DID keeps answering on the reverted head.

The flow above is complete: validated on save, simulated before publish, attached to a DID, and recoverable to any prior version. Start from it and change only the branches.
