{ 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 (typeivrStart), 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 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 explains those buckets — including how a node-level speech menu goes fully DTMF-free; this guide shows how both drop into a runnable flow.
2. Build the flow
Send the definition toPOST /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.
cURL
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) for the speech-only path.
3. Validate before you publish
Save-time graph validation already ran oncreate. 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).
cURL
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. Settype: "ivr" with config.flowId:
cURL
6. Hand off to an agent
Route thesupport 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 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-nodespeechIntents 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
nomatchhandle. 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.
speechInput node
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 — 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.