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
- Someone calls your Callr number: the
inbound-callbranch runs. gather@v4plays the menu (text-to-speech or an audio file of your library) and waits for a key press.- The result tells you which key was pressed (
$menu.input), or that nothing was pressed ($menu.timeout). ifconditions route the caller:dial@v2to a department,play@v3an 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: 1ends 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
thenWhen 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.okistrueonly when the call was bridged to the target. Busy, no answer and failures leave itfalse, so you can tell the caller before hanging up (or send them to voicemail).
🗣️ Voice or Keypad: recognize@v3
recognize@v3Let 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.
hintslists 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@v1The 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| Parameter | Default | Description |
|---|---|---|
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. |
stopOnDTMF | false | Stop listening on a key press and return the key in dtmf. |
timeout | 10 | Seconds to wait (1 to 60). |
Userecognize@v3where speaking is easier than typing: hands-free callers, long menus, or answers that are words rather than numbers. Keepgather@v4for 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
gather@v4 Resultresult:
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| Parameter | Default | Description |
|---|---|---|
tts | — | Text-to-speech prompt (text, optional voice or language). Set tts or audioFile, not both. |
audioFile | — | Audio file of your library (sid). |
maxDigits | 0 | Stop after this many digits. 0 waits for endOnKey or the timeout. |
endOnKey | # | Key that ends the input. |
timeout | 10 | Seconds to wait, prompt included (1 to 60). |
💡 Going Further
- Voice menus: use
recognize@v3withstopOnDTMF(above) to accept spoken answers as well as keys. - Multi-level menus: nest a second
gather@v4inside athenblock. - Collect a number: use
maxDigitswith a longertimeoutto capture an order or customer number, then look it up withfetch@v3(Fetch Data from Your Platform). - Ring several people: give
dial@v2severaltargets(tried in order), orparallel: trueto 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.
Updated 42 minutes ago
