Build a Voice IVR
Build a DTMF menu IVR using GetDigits, Say, and Dial voice actions with a Node.js webhook handler.
An IVR (Interactive Voice Response) menu lets callers navigate your phone system by pressing digits. This guide shows you how to build a two-level IVR using the Sautikit VoiceAction DSL with GetDigits, Say, and Dial verbs. The webhook handler is written in Node.js using Express, but the patterns apply to any language.
voice_callback_url configured. See Claim and route a number.ngrok or cloudflared.Map your menu options before writing code. For this guide the flow is:
Keep menus short. Callers prefer fewer than four options at any level.
Here is the complete voice-action response for the main menu:
{
"actions": [
{
"getDigits": {
"timeout": 5,
"numDigits": 1,
"finishOnKey": "#",
"nested": [
{
"say": {
"text": "Welcome to Acme. Press 1 for Sales. Press 2 for Support. Press 0 to repeat this menu.",
"voice": "alice",
"language": "en-US"
}
}
]
}
}
]
}Key parameters:
timeout: seconds to wait after the prompt finishes. Use 5 as a sensible default.numDigits: stop collecting after this many digits. 1 is correct for single-option menus.finishOnKey: digit that submits input early. Defaults to #; set to "" to disable.nested: verbs (Say or Play) that play while waiting for input.When the caller presses a digit, Sautikit POSTs back to your voice_callback_url with a Digits field in the body. Your server reads that field and returns the next action set.
// server.js
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: true })); // Sautikit POSTs form-encoded
app.use(express.json()); // also accept JSON bodies
const SALES_NUMBER = "+254722111111";
const SUPPORT_NUMBER = "+254722222222";
const FROM_NUMBER = "+254700000001"; // your Sautikit number
// Main IVR menu
app.post("/voice", (req, res) => {
const digits = req.body.Digits ?? req.body.digits ?? "";
if (digits === "1") {
return res.json(dialResponse(SALES_NUMBER, FROM_NUMBER));
}
if (digits === "2") {
return res.json(dialResponse(SUPPORT_NUMBER, FROM_NUMBER));
}
if (digits === "0") {
// Redirect back to this URL to replay the menu
return res.json({
actions: [{ redirect: { url: "https://your-server.example.com/voice", method: "POST" } }]
});
}
// No input or invalid digit: play the menu again
return res.json(mainMenu());
});
function mainMenu() {
return {
actions: [
{
getDigits: {
timeout: 5,
numDigits: 1,
nested: [
{
say: {
text: "Welcome to Acme. Press 1 for Sales. Press 2 for Support. Press 0 to repeat this menu.",
voice: "alice",
language: "en-US",
},
},
],
},
},
],
};
}
function dialResponse(toNumber, fromNumber) {
return {
actions: [
{
say: {
text: "Please hold while we connect you.",
voice: "alice",
language: "en-US",
},
},
{
dial: {
number: toNumber,
callerId: fromNumber,
timeout: 30,
},
},
{
say: {
text: "We could not connect your call. Please try again later.",
voice: "alice",
language: "en-US",
},
},
{ hangup: {} },
],
};
}
app.listen(3000, () => console.log("IVR server on :3000"));A few things to note:
Digits field is capitalised in the form-encoded body. Normalise the lookup to be safe.Dial, if the callee does not answer, call flow continues to the next verb: here, a polite message and hangup.When a Dial times out or is rejected, Sautikit continues executing remaining verbs in the response. Place a fallback Say and Hangup (or a Redirect) after every Dial:
{
"actions": [
{ "say": { "text": "Connecting you to support." } },
{
"dial": {
"number": "+254722222222",
"callerId": "+254700000001",
"timeout": 20
}
},
{
"say": {
"text": "All our agents are busy. Please call back in a few minutes."
}
},
{ "hangup": {} }
]
}Extend the handler to support a sub-menu for option 2:
app.post("/voice/support", (req, res) => {
const digits = req.body.Digits ?? "";
if (digits === "1") {
return res.json(dialResponse("+254722333333", FROM_NUMBER)); // billing
}
if (digits === "2") {
return res.json(dialResponse("+254722444444", FROM_NUMBER)); // technical
}
// Default: return sub-menu
return res.json({
actions: [
{
getDigits: {
timeout: 5,
numDigits: 1,
nested: [
{
say: {
text: "For billing, press 1. For technical support, press 2.",
voice: "alice",
language: "en-US",
},
},
],
},
},
],
});
});Then update the main handler to redirect option 2 to the sub-menu:
if (digits === "2") {
return res.json({
actions: [{ redirect: { url: "https://your-server.example.com/voice/support", method: "POST" } }]
});
}Using Redirect keeps each level in its own route handler, which is easier to test and extend than a single monolithic handler.
Test locally with curl:
# Simulate first call (no digits yet)
curl -s -X POST "http://localhost:3000/voice" \
-d "" | jq .actions[0].getDigits.nested[0].say.text
# Simulate pressing 1
curl -s -X POST "http://localhost:3000/voice" \
-d "Digits=1" | jq .actions[1].dial.number
# Simulate pressing 2
curl -s -X POST "http://localhost:3000/voice" \
-d "Digits=2" | jq .actions[0].redirect.urlThen configure your number's voice_callback_url to point at your tunnelled endpoint and place a test call from a mobile phone.
After the call, retrieve the call detail record and check the call_events for DTMF entries:
curl -s "https://api.sautikit.com/v1/calls?limit=1" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '.[0] | {id, status, duration_seconds}'