Building an IVR
Build a phone menu with Gather — prompts, branching, retries and routing to a human.
An IVR is a loop: play a prompt, collect key presses, decide what to do next.
In VoxML that is a Gather verb whose action_url returns the next document.
caller dials → your answer_url returns Gather
→ caller presses a key
→ TryVox POSTs the digits to action_url
→ you return the next VoxML → repeat or finishYour server holds all the logic; TryVox plays prompts and reports what was pressed.
Only DTMF (key presses) is collected. Speech input is not implemented —
speech_result is always empty. See Gather.
A working main menu
Your number's answer webhook returns:
{
"voxml_version": "1.0",
"instructions": [
{
"verb": "Gather",
"action_url": "https://example.com/ivr/main",
"num_digits": 1,
"timeout": 6,
"say": {
"text": "Thanks for calling Acme. For sales, press 1. For support, press 2. To hear this again, press 9."
}
}
]
}TryVox plays the prompt, waits, then POSTs to action_url:
{
"call_uuid": "abc-123",
"account_id": "TJ4530defa",
"digits": "1",
"speech_result": "",
"reason": "complete",
"from": "+919876543210",
"to": "+911234567890"
}Your handler returns the next document:
@app.post("/ivr/main")
def main_menu(req):
digits = req.json["digits"]
reason = req.json["reason"]
if reason != "complete" or digits == "":
return reprompt("Sorry, I didn't get that.")
if digits == "1":
return {"voxml_version": "1.0", "instructions": [
{"verb": "Say", "text": "Connecting you to sales."},
{"verb": "Dial", "number": "+919000000001"},
]}
if digits == "2":
return {"voxml_version": "1.0", "instructions": [
{"verb": "Redirect", "url": "https://example.com/ivr/support"}
]}
if digits == "9":
return {"voxml_version": "1.0", "instructions": [
{"verb": "Redirect", "url": "https://example.com/ivr/start"}
]}
return reprompt("That isn't one of the options.")Returning an empty instruction list ends the call, so always return something.
Handling no input and wrong input
digits alone cannot tell you whether the caller chose nothing or was cut off.
That is what reason is for:
reason | What happened | Usual response |
|---|---|---|
complete | Entered num_digits or pressed finish_on_key | Act on the digits |
timeout | The caller went quiet | Reprompt, then fall back |
cancelled | The call ended | Nothing — the call is gone |
error | Input could not be collected | Treat as no input |
A timeout is normal, not a failure. Give callers two attempts and then route them somewhere a human or a voicemail can take over:
def reprompt(message, attempt=1):
if attempt >= 3:
return {"voxml_version": "1.0", "instructions": [
{"verb": "Say", "text": "Let me put you through to someone."},
{"verb": "Dial", "number": "+919000000000"},
]}
return {"voxml_version": "1.0", "instructions": [
{"verb": "Gather",
"action_url": f"https://example.com/ivr/main?attempt={attempt + 1}",
"num_digits": 1,
"say": {"text": f"{message} For sales, press 1. For support, press 2."}}
]}Track the attempt count in your own action_url — the query string is the
simplest place, since TryVox posts back to exactly the URL you supply.
Collecting more than one digit
For an account number or PIN, set num_digits to the expected length. The
Gather returns as soon as that many keys are pressed, so the caller is not held
for the rest of the timeout:
{
"verb": "Gather",
"action_url": "https://example.com/ivr/account",
"num_digits": 8,
"timeout": 15,
"say": { "text": "Please enter your 8 digit account number." }
}When the length varies, drop num_digits and let the caller signal that they
are finished with finish_on_key (default #):
{
"verb": "Gather",
"action_url": "https://example.com/ivr/reference",
"finish_on_key": "#",
"timeout": 20,
"say": { "text": "Enter your reference number, then press hash." }
}The terminator is stripped from digits — it means "I'm done", it is not
part of the entry. A caller who enters 4321# gives you 4321.
Callers who know the menu often type before the prompt finishes. Those presses are buffered and count toward the Gather, so dial-ahead works without them having to type twice.
Nested menus
There is nothing special about a submenu — it is another Gather returned from
the previous one:
{
"voxml_version": "1.0",
"instructions": [
{
"verb": "Gather",
"action_url": "https://example.com/ivr/support",
"num_digits": 1,
"say": { "text": "For billing, press 1. For technical support, press 2. To go back, press 0." }
}
]
}Keep the depth shallow. Two levels is usually enough, and a "press 0 to go back" option costs one line and saves callers from being stuck.
Ending the call
Route to a person with Dial, take a message with Record, or finish with
Hangup:
{
"voxml_version": "1.0",
"instructions": [
{ "verb": "Say", "text": "Please leave a message after the tone." },
{ "verb": "Record",
"action_url": "https://example.com/ivr/voicemail",
"max_length": 120,
"finish_on_key": "#" },
{ "verb": "Say", "text": "Thank you. Goodbye." },
{ "verb": "Hangup" }
]
}Practical notes
- Prompt inside the Gather, not before it. A
Sayplaced before aGatherfinishes playing before collection starts, so anything pressed during it is buffered rather than acted on immediately. - Keep prompts short. Callers press as soon as they hear their option.
- Set
timeoutto suit the input. Six seconds for a menu choice is plenty; an account number needs longer. action_urlreceives a POST with a JSON body by default. Setmethodif you needGET.- Validate
call_uuidagainst your own records before acting on anything sensitive.