Skip to main content

Interactive Voice Response (IVR)

Build menu-driven call flows that collect caller input via DTMF keypad tones or speech, then branch the call dynamically. IVR is implemented entirely with NCCO talk and input actions returned from your webhook URLs.

🧭 How It Works​

  1. AfriRoute calls your answer_url when the call connects.
  2. You return an NCCO with a talk prompt and an input action.
  3. The caller presses keys (or speaks); AfriRoute posts the result to the input action's eventUrl.
  4. You return the next NCCO, branching on the input.

🔢 DTMF Menu​

app.post('/voice/answer', (req, res) => {
res.json([
{
action: 'talk',
text: 'Welcome to customer support. Press 1 for sales, 2 for support, or 3 for billing.'
},
{
action: 'input',
type: ['dtmf'],
eventUrl: ['https://yourapp.com/voice/dtmf'],
maxDigits: 1,
timeOut: 10
}
]);
});

app.post('/voice/dtmf', (req, res) => {
const digit = req.body.dtmf;
const departments = {
'1': { number: '+251911111111', name: 'Sales' },
'2': { number: '+251922222222', name: 'Support' },
'3': { number: '+251933333333', name: 'Billing' }
};
const dept = departments[digit];

if (dept) {
return res.json([
{ action: 'talk', text: `Connecting you to ${dept.name}.` },
{ action: 'connect', endpoint: [{ type: 'phone', number: dept.number }] }
]);
}
res.json([{ action: 'talk', text: 'Invalid selection. Goodbye.' }]);
});

🎛️ Input Parameters​

FieldTypeDescription
typearray["dtmf"], ["speech"], or both
maxDigitsnumberMaximum DTMF digits to collect
timeOutnumberSeconds to wait for input (default 10)
submitOnHashbooleanSubmit when # is pressed
eventUrlarrayWhere the collected input is posted

🎤 Speech Input​

[
{ "action": "talk", "text": "Please say your account number after the beep." },
{
"action": "input",
"type": ["speech"],
"speech": {
"endOnSilence": 2.0,
"language": "en-US",
"context": ["account", "number", "digits"]
},
"eventUrl": ["https://yourapp.com/voice/speech"]
}
]
@app.post('/voice/speech')
def speech():
results = request.json.get('speech', {}).get('results', [])
if results:
transcript = results[0]['text']
return jsonify([
{'action': 'talk', 'text': f'You said {transcript}. Is that correct?'}
])
return jsonify([{'action': 'talk', 'text': "Sorry, I didn't catch that."}])

🗂️ Multi-Level Menus​

function mainMenu() {
return [
{ action: 'talk', text: 'Main menu. Press 1 for account, 2 for support, 0 for operator.' },
{ action: 'input', type: ['dtmf'], maxDigits: 1, eventUrl: ['https://yourapp.com/voice/main'] }
];
}

function accountMenu() {
return [
{ action: 'talk', text: 'Account menu. Press 1 for balance, 2 for transactions, 9 to go back.' },
{ action: 'input', type: ['dtmf'], maxDigits: 1, eventUrl: ['https://yourapp.com/voice/account'] }
];
}

🔁 Reprompting & Timeouts​

When input returns no digits (timed_out), replay the menu so callers are never stranded:

app.post('/voice/main', (req, res) => {
if (!req.body.dtmf) return res.json(mainMenu()); // reprompt
// ... route on req.body.dtmf
});

💡 Best Practices​

  • Keep menus to 5 options max per level.
  • Offer an operator escape (Press 0) on every menu.
  • Reprompt on timeout instead of hanging up.
  • Confirm critical actions — "Press 1 to confirm, 2 to cancel".
  • Fall back to DTMF if speech recognition fails twice.
  • Cache static NCCO for menus that never change.

⚠️ Error Handling​

SituationRecommended response
No input (timed_out)Reprompt, then offer operator
Invalid digit"Invalid selection" + repeat menu
Low speech confidenceAsk the caller to repeat

Need help? Contact Support → | Last Updated: May 2026