> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wave.sa/llms.txt
> Use this file to discover all available pages before exploring further.

# IVR Menus

> Author a self-serve press-a-digit voice menu callers hear when they dial your number.

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](https://wave.sa)), 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](/voice/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.

<Note>
  This is the **single-level** menu — one greeting, one set of keypad options, each
  ending the call or handing it off. Nested sub-menus and a visual flow builder are
  separate, later features. For full branching logic today, return
  [WaveML](/voice/waveml) from your own endpoint instead.
</Note>

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

```json theme={null}
{
  "prompt": { "say": { "text": "To authenticate press 1, to decline press 2", "language": "en" } },
  "gather": { "num_digits": 1, "timeout_seconds": 8 },
  "branches": [
    { "digit": "1", "action": { "say": { "text": "You are authenticated. Goodbye." } }, "input": { "authenticated": true } },
    { "digit": "2", "action": { "say": { "text": "You chose not to authenticate. Goodbye." } }, "input": { "authenticated": false } }
  ],
  "no_input": { "action": { "say": { "text": "We didn't get your input. Goodbye." } } }
}
```

| Field                    | Type    | Required | Notes                                                                |
| ------------------------ | ------- | -------- | -------------------------------------------------------------------- |
| `prompt`                 | object  | **yes**  | The greeting. Exactly one of `say` or `play` (below).                |
| `gather.num_digits`      | integer | no       | Digits to collect, `1`–`10`. Default `1`.                            |
| `gather.timeout_seconds` | integer | no       | Seconds to wait for a keypress, `1`–`60`. Default `8`.               |
| `branches`               | array   | **yes**  | 1–12 keypad options. Each `digit` must be unique.                    |
| `no_input`               | object  | no       | Action taken if the caller presses nothing before `timeout_seconds`. |

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

| Action    | Shape                                             | What it does                                                     |
| --------- | ------------------------------------------------- | ---------------------------------------------------------------- |
| `say`     | `{ "text": "…", "language"?: "en" }`              | Speaks text (TTS). `text` ≤ 1000 chars.                          |
| `play`    | `{ "audio_ref": "https://…" }`                    | Plays a pre-recorded **audio prompt** (below). Prompt or action. |
| `dial`    | `{ "number": "0112345678" }`                      | Forwards the caller to a **KSA** number.                         |
| `enqueue` | `{ "queue": "support", "strategy"?: "ring-all" }` | Sends the caller into a [queue](/voice/queues).                  |
| `hangup`  | `true`                                            | Ends the call.                                                   |

<Note>
  `dial` targets are restricted to **Saudi (KSA) phone numbers** — a toll-fraud
  guard, since a `<Dial>` bridges the caller out to a new leg.
</Note>

### Branch input (your webhook payload)

Give a branch an `input` object and Wave merges it into the [`call.input`
webhook](/webhooks) 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` and `digits` 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.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.wave.sa/v1/callflows \
    -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "auth-ivr",
      "activate": true,
      "definition": {
        "prompt": { "say": { "text": "To authenticate press 1, to decline press 2" } },
        "branches": [
          { "digit": "1", "action": { "say": { "text": "You are authenticated. Goodbye." } }, "input": { "authenticated": true } },
          { "digit": "2", "action": { "say": { "text": "You chose not to authenticate. Goodbye." } }, "input": { "authenticated": false } }
        ]
      }
    }'
  ```

  ```javascript JavaScript theme={null}
  await fetch("https://api.wave.sa/v1/callflows", {
    method: "POST",
    headers: {
      Authorization: "Bearer sk_sandbox_xxxxxxxxxxxx",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "auth-ivr",
      activate: true,
      definition: {
        prompt: { say: { text: "To authenticate press 1, to decline press 2" } },
        branches: [
          { digit: "1", action: { say: { text: "You are authenticated. Goodbye." } }, input: { authenticated: true } },
          { digit: "2", action: { say: { text: "You chose not to authenticate. Goodbye." } }, input: { authenticated: false } },
        ],
      },
    }),
  });
  ```
</CodeGroup>

| Field        | Type    | Required | Notes                                                                        |
| ------------ | ------- | -------- | ---------------------------------------------------------------------------- |
| `name`       | string  | **yes**  | `A–Z a–z 0–9 _ - ` and spaces, ≤100 chars. Unique per org — the routing key. |
| `definition` | object  | **yes**  | The **menu definition** (above).                                             |
| `activate`   | boolean | no       | `true` makes this version live on create. Default `false`.                   |

`201 Created` returns the menu, including its version history:

```json theme={null}
{
  "name": "auth-ivr",
  "version": 1,
  "is_active": true,
  "definition": { "kind": "menu", "gather": { "…": "…" }, "branches": { "…": "…" } },
  "created_at": "2026-08-28T10:00:00.000Z",
  "updated_at": "2026-08-28T10:00:00.000Z",
  "versions": [ { "version": 1, "is_active": true, "created_at": "2026-08-28T10:00:00.000Z" } ]
}
```

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):

```json theme={null}
{
  "data": [
    { "name": "auth-ivr", "version": 2, "is_active": true, "updated_at": "2026-08-28T11:00:00.000Z" }
  ]
}
```

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

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

<CodeGroup>
  ```bash cURL theme={null}
  # Roll back to version 1
  curl -X POST https://api.wave.sa/v1/callflows/auth-ivr/activate \
    -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{ "version": 1 }'
  ```

  ```javascript JavaScript theme={null}
  await fetch("https://api.wave.sa/v1/callflows/auth-ivr/activate", {
    method: "POST",
    headers: {
      Authorization: "Bearer sk_sandbox_xxxxxxxxxxxx",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ version: 1 }),
  });
  ```
</CodeGroup>

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.

```bash cURL theme={null}
curl -X POST https://api.wave.sa/v1/callflows/auth-ivr/bind-number \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "phone_number_id": "f933e0c3-45a9-4b25-b469-624745a3f8ef" }'
```

| Field             | Type | Required | Notes                                                                                     |
| ----------------- | ---- | -------- | ----------------------------------------------------------------------------------------- |
| `phone_number_id` | uuid | **yes**  | A number your org owns. `404` if it isn't yours; `409` if the menu has no active version. |

Returns `{ "bound": true }`. Numbers are Wave-provided — see [Virtual
Numbers](/voice/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`](/webhooks) webhook. Its
`data` carries the pressed `digits` **plus** that branch's `input` object — so your
backend learns exactly what the caller chose:

```json theme={null}
{
  "event": "call.input",
  "data": { "call_id": "…", "digits": "1", "authenticated": true }
}
```

Register an endpoint and verify the signature as described in
[Webhooks](/webhooks).

## 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`:

```bash cURL theme={null}
curl -X POST https://api.wave.sa/v1/callflows/audio \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "base64": "<base64-wav>", "content_type": "audio/wav", "file_name": "welcome.wav" }'
```

```json theme={null}
{ "audio_ref": "https://<bucket>.<region>.aliyuncs.com/prompts/<org>/<id>.wav" }
```

Use the `audio_ref` in a `play`:

```json theme={null}
{ "prompt": { "play": { "audio_ref": "https://…/prompts/<org>/<id>.wav" } } }
```

<Note>
  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.
</Note>

## Next steps

* [WaveML](/voice/waveml) — the verbs a menu compiles to, and full custom flows.
* [Webhooks](/webhooks) — receive and verify `call.input`.
* [Queues](/voice/queues) — where an `enqueue` branch sends the caller.
