Skip to main content
An IVR menu greets a caller, reads out a few options, and routes them by the key they press — “press 1 for sales, press 2 for support.” You author the menu once with this API (or the dashboard), bind it to one of your Wave numbers, and Wave serves it live to every caller — no redeploy. Under the hood a menu compiles to a WaveML <Gather> that plays your greeting and collects one keypress, then runs the branch for that digit. This API is the authoring layer on top of WaveML — you describe the menu as JSON and Wave turns it into the call flow.
This page starts with the single-level menu — one greeting, one set of keypad options, each ending the call or handing it off. For sub-menus, “press 9 for the main menu” and repeat-on-silence, write a multi-level menu (a graph) — see Multi-level menus (graph) below. A visual flow builder is a separate, later feature.

Authentication & tiers

All /v1/callflows endpoints take an API key with the callflows:read or callflows:write scope, or a dashboard session. You can author and test menus with a sandbox key (sk_sandbox_); serving a menu to a real inbound call needs a production key (sk_live_), a production-tier org, and a bound number (see Bind a number below).

The menu definition

A menu is a JSON definition: a greeting, the keypad branches, and an optional fallback for when the caller presses nothing.

Prompt and actions

A prompt and every branch action is one of these. A branch action is a terminal leaf — it ends the call or hands it off.
dial targets are restricted to Saudi (KSA) phone numbers — a toll-fraud guard, since a <Dial> bridges the caller out to a new leg.

Branch input (your webhook payload)

Give a branch an input object and Wave merges it into the call.input webhook when the caller picks that option — your integration’s hook for what the caller chose.
  • Up to 20 fields; values are string (≤512), number, or boolean.
  • The keys call_id, digits, node and limit_reached are reserved (Wave sets them) and rejected.

Create a menu

POST /v1/callflows — create a menu by name. Pass "activate": true to make it live immediately.
201 Created returns the menu, including its version history:
A duplicate name returns 409 — use PUT to add a version instead.

List and fetch menus

GET /v1/callflows lists one row per menu (its active or latest version):
GET /v1/callflows/{name} returns the full menu — the active (or latest) version’s definition plus the complete versions history. 404 if the menu doesn’t exist. Add ?version=N to read one version instead — for example a saved draft that is not live, or an older version: GET /v1/callflows/auth-ivr?version=3. The response has the same shape; is_active tells you whether that version is live. 404 if that version doesn’t exist; 400 if N is not a positive integer. Each flow has a kind: menu (single-level) or graph (multi-level). The definition comes back in the shape you wrote it: a menu as its prompt, gather, branches and no_input, and a graph with its ui fields. So you can send a definition that you read back to PUT with no change. A flow that Wave set up for you (not through this API) can read back in a simpler form: each option shows only its main action. If you PUT it back, the new version stores that simpler form.

Versioning and rollback

Menus are immutable and append-only. Editing never mutates a version in place — it appends a new one.
  • PUT /v1/callflows/{name} with a new definition (and optional activate) adds the next version. 404 if the menu doesn’t exist.
  • To avoid lost updates, also send base_version: the version you read and edited. If someone saved a later version first, the PUT returns 409 CALLFLOW_VERSION_CONFLICT and writes nothing; the message names the latest version. Read that version, apply your change again, and send it as the new base_version. Without base_version, PUT always appends (as before).
  • POST /v1/callflows/{name}/activate with { "version": N } makes version N live — rollback is just activating an older version. 404 if that version doesn’t exist.
Both return the menu object with the newly-active version.

Bind a number

POST /v1/callflows/{name}/bind-number points one of your Wave numbers at the menu’s active version — inbound calls to that number then run the menu.
Returns { "bound": true }. Numbers are Wave-provided — see Virtual Numbers.

Retire a menu

DELETE /v1/callflows/{name} soft-retires a menu: it stops serving callers and any bound number is unlinked, but the version history is kept (you can recreate it later). Returns { "retired": true }; 404 if the menu doesn’t exist.

The call.input webhook

When a caller presses a digit, Wave sends a call.input webhook. Its data carries the pressed digits plus that branch’s input object — so your backend learns exactly what the caller chose:
Register an endpoint and verify the signature as described in Webhooks.

Multi-level menus (graph)

A graph is a menu with more than one level. You write it as a set of named nodes. Each node does one thing and points to the next node. The call starts at the start node. Send a graph to the same routes as a menu: POST /v1/callflows and PUT /v1/callflows/{name}, with "kind": "graph" in the definition. Activate, rollback, retire and bind work the same as for a menu.

Example: two levels, “press 9 for the main menu”

Nodes

Node ids use lower-case letters and digits, 1–12 characters (main, sales2). Every node can also have ui: { "x": number, "y": number, "label"?: string } — layout data for a visual builder. label is the node’s display name in any language (for example "القائمة الرئيسية"), 1–40 characters after spaces are trimmed, with no control characters or bidi overrides. Wave trims label and otherwise stores ui and gives it back unchanged. It does not change the call: the node id stays the routing key, and label never goes to the caller or to your webhook.

No input, wrong key, and the loop limit

  • No key before timeout_seconds: the call goes to the menu’s no_input node.
  • A key that is not an option: the call goes to the menu’s invalid node.
  • If you do not set no_input or invalid, Wave plays the same menu again.
  • A call can take 20 menu turns at most. At the next menu, the call goes to your on_limit node. If there is no on_limit, the call ends.

Rules that Wave checks when you save

Wave rejects a graph with a 400 and names the field that is wrong (for example definition.nodes.sales.options.0.next) when:
  • start or on_limit is not a node, or a next points to a node that does not exist.
  • A node cannot be reached from start. (Nodes that only on_limit reaches are permitted.)
  • The graph has more than 50 nodes, or a menu has more than 12 options or the same digit two times.
  • A loop does not go through a menu node. (The call would never stop.)
  • The on_limit path can reach a menu node.
  • A dial target is not a KSA number, or an audio_ref (in a play node, a menu prompt or moh) is not a file that your org uploaded.

call.input for a graph

Wave sends one call.input webhook each time the caller answers a menu node — also for no input and for a wrong key. data has the digits, the option’s input, and node (the id of the menu node). When the call gets to the 20-turn limit, data also has "limit_reached": true.
After a dial or enqueue node, the call ends. Wave does not send another call.input for it.

Audio prompts

A play prompt or action plays a pre-recorded file instead of TTS — good for a produced greeting, and it sidesteps synthesis latency. Upload the file first, then reference the returned audio_ref. POST /v1/callflows/audio takes a base64-encoded WAV file (8 kHz mono 16-bit PCM recommended — telephony quality) and returns a Wave-hosted audio_ref:
Use the audio_ref in a play:
Audio upload is rolling out. Until it’s enabled for your account, the upload returns 503 and menus use say (TTS) prompts. An audio_ref must be a file you uploaded — arbitrary URLs are rejected.

Next steps

  • WaveML — the verbs a menu compiles to, and full custom flows.
  • Webhooks — receive and verify call.input.
  • Queues — where an enqueue branch sends the caller.