Build an IVR Menu

Greet callers, let them press a key to choose a department, and route the call with gather@v4 and dial@v2 — the classic "press 1 for sales" menu, in a few lines of YAML.

👍

An IVR (Interactive Voice Response) menu greets callers and lets them choose where to go with their keypad: sales, support, opening hours, a human… It is the first thing most businesses build on a phone number.

⚙️ How It Works

  1. Someone calls your Callr number: the inbound-call branch runs.
  2. gather@v4 plays the menu (text-to-speech or an audio file of your library) and waits for a key press.
  3. The result tells you which key was pressed ($menu.input), or that nothing was pressed ($menu.timeout).
  4. if conditions route the caller: dial@v2 to a department, play@v3 an information message, or a second chance to choose.

🚀 Simple Menu

A two-choice menu. Each choice forwards the call to a different phone number.

description: IVR menu - press 1 for sales, 2 for support

compat:
  version: "2025-09-01"

defaults:
  language: en-US

variables:
  $sales: "+33199001020"
  $support: "+33199001030"
  $menu: null
  $dial: null

branches:
  inbound-call:
    actions:
      # Play the menu and wait for ONE key
      - action: gather@v4
        params:
          tts:
            text: Welcome to Acme. For sales, press 1. For support, press 2.
          maxDigits: 1
          timeout: 10
        result: $menu

      - if: $menu.input === "1"
        action: dial@v2
        params:
          targets:
            - number: ${{ $sales }}
              timeout: 30
        result: $dial

      - if: $menu.input === "2"
        action: dial@v2
        params:
          targets:
            - number: ${{ $support }}
              timeout: 30
        result: $dial

      # No key, or an unknown key
      - if: '!$dial'
        action: play@v3
        params:
          tts:
            text: Sorry, we did not get your choice. Goodbye.

      - action: hangup@v1
📘

maxDigits: 1 ends the gather as soon as one key is pressed, so the caller does not have to press #. The first key pressed also stops the prompt: regular callers can choose without listening to the whole menu.


🔁 Give the Caller a Second Chance

Callers hesitate. Instead of hanging up on a wrong key, repeat the menu once with a shorter prompt. $menu.timeout is true when nothing was pressed in time.

- action: gather@v4
  params:
    tts:
      text: Welcome to Acme. For sales, press 1. For support, press 2.
    maxDigits: 1
    timeout: 10
  result: $menu

# Nothing pressed, or not a valid choice: ask again
- if: $menu.timeout || !["1", "2"].includes($menu.input)
  action: gather@v4
  params:
    tts:
      text: Press 1 for sales, or 2 for support.
    maxDigits: 1
    timeout: 10
  result: $menu

🧱 Grouping Actions with then

When a choice needs several actions (a message, then a dial, then a fallback), group them in a then block instead of repeating the same if on each action.

description: IVR menu with opening hours and a fallback per department

compat:
  version: "2025-09-01"

defaults:
  language: en-US

variables:
  $sales: "+33199001020"
  $support: "+33199001030"
  $menu: null
  $dial: null

branches:
  inbound-call:
    actions:
      - action: gather@v4
        params:
          tts:
            text: Welcome to Acme. For sales, press 1. For support, press 2. For our opening hours, press 3.
          maxDigits: 1
          timeout: 10
        result: $menu

      - if: $menu.input === "1"
        then:
          - action: play@v3
            params:
              tts:
                text: Connecting you to sales.
          - action: dial@v2
            params:
              targets:
                - number: ${{ $sales }}
                  timeout: 30
            result: $dial
          - if: '!$dial.ok'
            action: play@v3
            params:
              tts:
                text: Our sales team is not available right now. Please call back later.

      - if: $menu.input === "2"
        then:
          - action: play@v3
            params:
              tts:
                text: Connecting you to support.
          - action: dial@v2
            params:
              targets:
                - number: ${{ $support }}
                  timeout: 30
            result: $dial
          - if: '!$dial.ok'
            action: play@v3
            params:
              tts:
                text: Our support team is not available right now. Please call back later.

      - if: $menu.input === "3"
        action: play@v3
        params:
          tts:
            text: We are open Monday to Friday, from 9 AM to 6 PM.

      - action: hangup@v1
ℹ️

$dial.ok is true only when the call was bridged to the target. Busy, no answer and failures leave it false, so you can tell the caller before hanging up (or send them to voicemail).


🗣️ Voice or Keypad: recognize@v3

Let callers say their choice or press a key — whichever they prefer. recognize@v3 transcribes what the caller says (speech-to-text), and with stopOnDTMF: true it also stops as soon as a key is pressed and returns it in dtmf.

  • hints lists the words you expect: they improve recognition of short answers like "sales" or "support".
  • The result has the transcript (lexical, text) or the key pressed (dtmf), so one condition handles both.
description: IVR menu - say or press your choice

compat:
  version: "2025-09-01"

defaults:
  language: en-US

variables:
  $sales: "+33199001020"
  $support: "+33199001030"
  $answer: null
  $choice: null
  $dial: null

branches:
  inbound-call:
    actions:
      - action: recognize@v3
        params:
          tts:
            text: Welcome to Acme. Say sales or press 1. Say support or press 2.
          language: en-US # speech-to-text language
          hints: [sales, support]
          stopOnDTMF: true # a key press stops listening and is returned in dtmf
          timeout: 10
        result: $answer

      # One choice, whether it was spoken or pressed
      - action: javascript@v1
        params:
          code: |
            const said = ($answer.lexical || "").toLowerCase()
            if ($answer.dtmf === "1" || said.includes("sales")) $choice = "sales"
            else if ($answer.dtmf === "2" || said.includes("support")) $choice = "support"

      - if: $choice === "sales"
        action: dial@v2
        params:
          targets:
            - number: ${{ $sales }}
              timeout: 30
        result: $dial

      - if: $choice === "support"
        action: dial@v2
        params:
          targets:
            - number: ${{ $support }}
              timeout: 30
        result: $dial

      - if: '!$choice'
        action: play@v3
        params:
          tts:
            text: Sorry, we did not get your choice. Goodbye.

      - action: hangup@v1

The recognize@v3 result:

result:
  ok: true
  lexical: sales        # what was said, without punctuation
  text: Sales.          # formatted, with punctuation
  confidence: 0.90      # 0..1
  dtmf: ""              # the key pressed, when stopOnDTMF is enabled
  stoppedOnDTMF: false  # true when a key ended the recognition
  timeout: false        # true when nothing was said or pressed in time
ParameterDefaultDescription
tts / audioFile—Prompt played before listening (set at most one).
language—Speech-to-text language. The prompt language is tts.language or tts.voice.
hints—Up to 10 expected words or phrases, to improve recognition.
stopOnDTMFfalseStop listening on a key press and return the key in dtmf.
timeout10Seconds to wait (1 to 60).
📘

Use recognize@v3 where speaking is easier than typing: hands-free callers, long menus, or answers that are words rather than numbers. Keep gather@v4 for numeric input such as order or customer numbers.


🎧 Use Your Own Recordings

Text-to-speech is billed every time a prompt is played. Upload your prompts once as audio files and reference them by SID: audio file prompts are free.

- action: gather@v4
  params:
    audioFile:
      sid: fdsanjfd # your menu recording
    maxDigits: 1
    timeout: 15 # the timeout includes the prompt: make it longer than your recording
  result: $menu

📊 The gather@v4 Result

result:
  ok: true
  timeout: false     # true when no key was pressed in time
  input: "1"         # the keys pressed, never including endOnKey
  stoppedOnKey: ""   # the key that ended the gather, if any
  recognized: ""     # spoken words, when stopOnTalk is enabled
  stoppedOnTalk: false
ParameterDefaultDescription
tts—Text-to-speech prompt (text, optional voice or language). Set tts or audioFile, not both.
audioFile—Audio file of your library (sid).
maxDigits0Stop after this many digits. 0 waits for endOnKey or the timeout.
endOnKey#Key that ends the input.
timeout10Seconds to wait, prompt included (1 to 60).

💡 Going Further

  • Voice menus: use recognize@v3 with stopOnDTMF (above) to accept spoken answers as well as keys.
  • Multi-level menus: nest a second gather@v4 inside a then block.
  • Collect a number: use maxDigits with a longer timeout to capture an order or customer number, then look it up with fetch@v3 (Fetch Data from Your Platform).
  • Ring several people: give dial@v2 several targets (tried in order), or parallel: true to ring them all at once.
  • Voicemail when nobody answers: see Voicemail & Answering Machine Detection.
  • Let an AI agent answer instead: see Multi-Channel AI Agents.

Did this page help you?