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
- AfriRoute calls your
answer_urlwhen the call connects. - You return an NCCO with a
talkprompt and aninputaction. - The caller presses keys (or speaks); AfriRoute posts the result to the
inputaction'seventUrl. - 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
| Field | Type | Description |
|---|---|---|
type | array | ["dtmf"], ["speech"], or both |
maxDigits | number | Maximum DTMF digits to collect |
timeOut | number | Seconds to wait for input (default 10) |
submitOnHash | boolean | Submit when # is pressed |
eventUrl | array | Where 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
| Situation | Recommended response |
|---|---|
No input (timed_out) | Reprompt, then offer operator |
| Invalid digit | "Invalid selection" + repeat menu |
| Low speech confidence | Ask the caller to repeat |
📚 Related Resources
Need help? Contact Support → | Last Updated: May 2026