Build a Voice IVR
Build a keypad menu IVR on Sautikit with getDigits, say and dial voice actions in Node.js. Incoming calls are free; dial costs KES 3.00 a minute.
Build a keypad menu IVR on Sautikit with getDigits, say and dial voice actions in Node.js. Incoming calls are free; dial costs KES 3.00 a minute.
An IVR (interactive voice response) menu lets callers route themselves by pressing keys. On Sautikit you build one with the getDigits, say and dial voice actions: the incoming call is free, and each dial to a phone number is charged at KES 3.00 a minute. The handler below is Node.js with Express; the same JSON works from any language.
voice_callback_url set. See Claim and route a number.ngrok or cloudflared.Map the options before writing code. This example uses:
Keep menus short: four options or fewer at each level.
{
"actions": [
{
"getDigits": {
"timeout": 5,
"numDigits": 1,
"finishOnKey": "#",
"nested": [
{
"say": {
"text": "Welcome. Press 1 for sales. Press 2 for support. Press 0 to hear this menu again."
}
}
]
}
}
]
}nested: the prompt (say or play) the caller hears while Sautikit listens for keys.numDigits: stop after this many digits. 1 suits a single-key menu.timeout: seconds to wait after the prompt ends.finishOnKey: the key that submits early. The default is #.getDigits has no URL of its own. When the caller presses a key, Sautikit POSTs the digits to the number's voice callback URL as dtmfDigits (read digits as a fallback), and your server replies with the next actions.
// server.js
import express from "express";
const app = express();
app.use(express.json());
const SALES_NUMBER = "+254720000000";
const SUPPORT_NUMBER = "+254720000001";
const FROM_NUMBER = "+254720000002"; // your Sautikit number
// The pressed keys arrive as dtmfDigits. Read digits as a fallback.
const pressed = (body) => body.dtmfDigits ?? body.digits ?? "";
// Sautikit also posts progress updates while a dial rings, connects and ends.
// They need no new actions.
const isUpdate = (body) => ["Dialing", "Bridged", "Completed"].includes(body.callSessionState);
app.post("/voice", (req, res) => {
if (isUpdate(req.body)) return res.json({ actions: [] });
const digits = pressed(req.body);
if (digits === "1") return res.json(dialResponse(SALES_NUMBER));
if (digits === "2") return res.json(dialResponse(SUPPORT_NUMBER));
// First callback, no input, 0 or an unknown key: play the menu.
return res.json(mainMenu());
});
function mainMenu() {
return {
actions: [
{
getDigits: {
timeout: 5,
numDigits: 1,
nested: [
{
say: {
text: "Welcome. Press 1 for sales. Press 2 for support. Press 0 to hear this menu again.",
},
},
],
},
},
],
};
}
function dialResponse(toNumber) {
return {
actions: [
{ say: { text: "Please hold while we connect you." } },
{ dial: { number: toNumber, callerId: FROM_NUMBER, timeout: 30 } },
{ say: { text: "We could not connect your call. Please try again later." } },
{ hangup: {} },
],
};
}
app.listen(3000, () => console.log("IVR server on :3000"));dial takes number (one destination), numbers (several) or sip. timeout is how long to ring, in seconds.
When a dial times out or is rejected, Sautikit carries on with the actions after it. Put a fallback say and hangup (or a redirect) after every dial:
{
"actions": [
{ "say": { "text": "Connecting you to support." } },
{
"dial": {
"number": "+254720000001",
"callerId": "+254720000002",
"timeout": 20
}
},
{ "say": { "text": "All our agents are busy. Please call back in a few minutes." } },
{ "hangup": {} }
]
}redirect moves the call to another URL on your server, and later key presses go to that URL. Use it to give each menu its own route:
// In the /voice handler, replace the "2" branch:
if (digits === "2") {
return res.json({
actions: [{ redirect: { url: "https://your-server.example.com/voice/support", method: "POST" } }],
});
}
app.post("/voice/support", (req, res) => {
if (isUpdate(req.body)) return res.json({ actions: [] });
const digits = pressed(req.body);
if (digits === "1") return res.json(dialResponse("+254720000003")); // billing
if (digits === "2") return res.json(dialResponse("+254720000004")); // technical
return res.json({
actions: [
{
getDigits: {
timeout: 5,
numDigits: 1,
nested: [{ say: { text: "For billing, press 1. For technical support, press 2." } }],
},
},
],
});
});Test the handler locally with curl:
# First callback: the menu
curl -s -X POST "http://localhost:3000/voice" \
-H "Content-Type: application/json" -d '{}' | jq '.actions[0].getDigits.nested[0].say.text'
# Caller pressed 1
curl -s -X POST "http://localhost:3000/voice" \
-H "Content-Type: application/json" -d '{"dtmfDigits":"1"}' | jq '.actions[1].dial.number'
# Caller pressed 2
curl -s -X POST "http://localhost:3000/voice" \
-H "Content-Type: application/json" -d '{"dtmfDigits":"2"}' | jq '.actions[0].redirect.url'Then point your number's voice_callback_url at the tunnelled endpoint and call it from a mobile phone. Afterwards, read the call record and its events:
curl -s "https://api.sautikit.com/v1/calls?limit=1" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '.data[0] | {id, status, duration_seconds, events}'The incoming call is free. Each dial to a phone number is charged at KES 3.00 a minute, billed per second after it is answered.