---
title: >-
  Serve Kenyan callers faster: build a KES IVR menu for payments, balances, and
  routing
description: >-
  Build a four-level IVR tree with Sautikit: main menu, payment confirmation,
  agent transfer, callback scheduling, with Swahili/English prompts in KES.
summary: >-
  This guide builds a four-level IVR tree with Sautikit: main menu, payment
  confirmation, agent transfer, and callback scheduling, with localised
  Swahili/English prompts and real KES amounts.
date: 2026-07-16T00:00:00.000Z
type: blog
---


## Summary

IVR menus are the backbone of Kenyan fintech customer service: balance checks, loan repayment confirmations, and M-Pesa dispute routing all flow through them. This guide builds a four-level IVR tree with Sautikit's JSON voice-action verbs: main menu, payment confirmation branch, agent transfer branch, and a callback scheduling branch, with localised Swahili/English prompts and KES amounts voiced correctly.

## Designing the IVR tree before writing code

Draw the call flow on paper before touching code. Each branch is a `GetDigits` → Webhook → next voice response cycle. A four-level tree for a Kenyan fintech product might look like:

```
Inbound call
└── Main menu (press 1, 2, 3, or 0)
    ├── 1 → Payment confirmation branch
    │       └── Read back last transaction, press 1 to confirm
    ├── 2 → Agent transfer branch
    │       └── Dial to agent pool
    ├── 3 → Callback scheduling branch
    │       └── Record voice message, confirm slot
    └── 0 → Repeat menu
```

Key design rules before implementation:

- Every `GetDigits` must have a timeout and a fallback action for when no input is received
- Every `Redirect` must respond within 5 seconds or the call falls back to the previous state
- Keep menu depth to 3 levels maximum: callers on mobile audio lose context past the third branch

<!-- diagram omitted -->

## The root menu: Say + GetDigits + Redirect

When a call is answered, Sautikit POSTs the call state to your `voice_callback_url`. Return the main menu:

```js
// app.js, Express webhook handler for an inbound IVR
import express from "express";

const app = express();
app.use(express.urlencoded({ extended: false }));

const BASE_URL = process.env.BASE_URL;

app.post("/voice", (req, res) => {
  // Main IVR menu: entry point for all inbound calls.
  res.json({
    actions: [
      {
        say: {
          text:
            "Karibu. Welcome to Acme Finance. " +
            "For payment confirmation, press 1. " +
            "Kwa uthibitisho wa malipo, bonyeza moja. " +
            "To speak with an agent, press 2. " +
            "To schedule a callback, press 3. " +
            "To repeat this menu, press 0.",
          language: "en-KE",
        },
      },
      {
        getDigits: {
          numDigits: 1,
          timeout: 8000,
          finishOnKey: "", // collect immediately on any digit
          action: `${BASE_URL}/menu-branch`,
        },
      },
      // Fallback if no input
      {
        say: {
          text: "We did not receive your selection. Hatukupokea chaguo lako. Goodbye.",
          language: "en-KE",
        },
      },
      { hangup: {} },
    ],
  });
});
```

## Branching on digit: webhook handler pattern

The `action` URL receives the digit the caller pressed. Route to the correct branch handler:

```js
function redirectTo(res, path) {
  // Return a Redirect verb to another webhook path.
  res.json({
    actions: [{ redirect: { url: `${BASE_URL}${path}` } }],
  });
}

app.post("/menu-branch", (req, res) => {
  const digit = req.body.Digits || "";

  switch (digit) {
    case "1":
      return redirectTo(res, "/payment-confirmation");
    case "2":
      return redirectTo(res, "/agent-transfer");
    case "3":
      return redirectTo(res, "/callback-schedule");
    case "0":
      return redirectTo(res, "/voice");
    default:
      // Unrecognised digit: replay menu
      return redirectTo(res, "/voice");
  }
});
```

The `Redirect` verb hands off call control to the target URL. The 5-second deadline is the time your target handler has to return a response. If it takes longer, Sautikit treats it as a timeout and re-plays the previous menu state. Keep your branch handlers lean: any database query in the handler path must complete within ~2 seconds to leave margin for network round-trip.

## Payment confirmation branch: reading back KES amounts

Reading a KES amount aloud in a way that Kenyan callers understand requires formatting. The `Say` verb with `language: sw-KE` can voice Swahili numbers natively, but the amount must be formatted as the TTS engine expects it.

The reliable approach is to format the amount explicitly in text:

```js
const ONES = ["", "moja", "mbili", "tatu", "nne", "tano",
              "sita", "saba", "nane", "tisa"];
const TENS = ["", "kumi", "ishirini", "thelathini", "arobaini",
              "hamsini", "sitini", "sabini", "themanini", "tisini"];

function hundreds(n) {
  const h = Math.floor(n / 100);
  const t = Math.floor((n % 100) / 10);
  const o = n % 10;
  const parts = [];
  if (h > 0) parts.push(h > 1 ? `mia ${ONES[h]}` : "mia moja");
  if (t > 0) {
    parts.push(TENS[t]);
    if (o > 0) parts.push(`na ${ONES[o]}`);
  } else if (o > 0) {
    parts.push(ONES[o]);
  }
  return parts.join(" ");
}

// Format a KES amount as Swahili words for the Say verb.
// Handles amounts from 0 to 999,999.
// E.g. 1250.50 → "shilingi elfu moja mia mbili hamsini na senti hamsini"
function formatKesSwahili(amountKes) {
  const shillings = Math.trunc(amountKes);
  const cents = Math.round((amountKes - shillings) * 100);

  const parts = [];
  const thousands = Math.floor(shillings / 1000);
  const remainder = shillings % 1000;

  if (thousands > 0) {
    parts.push(thousands === 1 ? "elfu moja" : `elfu ${hundreds(thousands)}`);
  }
  if (remainder > 0) {
    parts.push(hundreds(remainder));
  }

  let result = parts.length ? `shilingi ${parts.join(" ")}` : "shilingi sifuri";

  if (cents > 0) {
    result += ` na senti ${hundreds(cents)}`;
  }

  return result;
}
```

Use it in the payment confirmation handler:

```js
app.post("/payment-confirmation", async (req, res) => {
  const caller = req.body.From || "";
  // Fetch the last transaction for this caller from your database
  const txn = await getLastTransaction(caller);

  if (!txn) {
    return res.json({
      actions: [
        { say: { text: "We could not find a recent transaction on your account.", language: "en-KE" } },
        { redirect: { url: `${BASE_URL}/voice` } },
      ],
    });
  }

  const amountText = formatKesSwahili(txn.amount_kes);
  const ref = txn.reference;

  res.json({
    actions: [
      {
        say: {
          text:
            `Your last payment was ${amountText}, ` +
            `reference ${ref}. ` +
            "Press 1 to confirm receipt, or 2 to report a dispute.",
          language: "en-KE",
        },
      },
      {
        getDigits: {
          numDigits: 1,
          timeout: 8000,
          action: `${BASE_URL}/payment-confirm-response`,
        },
      },
      { hangup: {} },
    ],
  });
});
```

## Agent transfer branch: Dial to a live agent number

The `Dial` verb connects the caller to an agent. Importantly, the caller's original `From` number is preserved in the transferred call's metadata; the agent receives it in the `X-Original-Caller` field in the Sautikit webhook at the agent leg.

```js
app.post("/agent-transfer", (req, res) => {
  // The agent pool number: a queue or direct line
  const AGENT_NUMBER = "+254720000100";

  res.json({
    actions: [
      {
        say: {
          text: "Connecting you to an agent. Please hold.",
          language: "en-KE",
        },
      },
      {
        dial: {
          number: AGENT_NUMBER,
          // The caller's From number is automatically forwarded
          // as X-Original-Caller in the webhook to the agent leg.
          // Your agent system can read it to pre-populate the screen.
          timeout: 30,
          action: `${BASE_URL}/agent-transfer-complete`,
        },
      },
      // If agent doesn't answer:
      {
        say: {
          text: "All agents are busy. Press 3 to leave a callback request.",
          language: "en-KE",
        },
      },
      { redirect: { url: `${BASE_URL}/callback-schedule` } },
    ],
  });
});
```

On the agent side, read the caller's number from the Sautikit webhook:

```js
app.post("/agent-webhook", (req, res) => {
  // When Sautikit dials the agent, it includes the original caller's number
  const originalCaller =
    req.get("X-Original-Caller") || req.body.originalCaller || "unknown";

  // Pre-populate the CRM screen for the agent
  crmLookup(originalCaller);

  res.json({
    actions: [{ say: { text: "Connecting customer now.", language: "en-KE" } }],
  });
});
```

This pattern means the agent never needs to ask "what number are you calling from?"; the number is already in the CRM before the agent picks up.

## Callback scheduling: Record a callback request

The callback branch records a short voice message from the caller and schedules a return call. The `Record` verb captures audio and delivers the recording URL via webhook:

```js
app.post("/callback-schedule", (req, res) => {
  res.json({
    actions: [
      {
        say: {
          text:
            "Please leave your name and a brief reason for your call " +
            "after the tone. Press the hash key when you are done.",
          language: "en-KE",
        },
      },
      {
        record: {
          maxLength: 60, // 60-second maximum
          finishOnKey: "#",
          action: `${BASE_URL}/callback-recorded`,
          playBeep: true,
        },
      },
      { hangup: {} },
    ],
  });
});

app.post("/callback-recorded", (req, res) => {
  const recordingUrl = req.body.RecordingUrl;
  const caller = req.body.From || "";
  const duration = req.body.RecordingDuration || 0;

  // Store the callback request in your queue
  saveCallbackRequest(caller, recordingUrl, duration);

  res.json({
    actions: [
      {
        say: {
          text:
            "Thank you. We have received your callback request " +
            "and will call you back within 2 hours.",
          language: "en-KE",
        },
      },
      { hangup: {} },
    ],
  });
});
```

## Where to host your webhook handler

The handler is nothing exotic: a public HTTPS endpoint that receives Sautikit's JSON call event and returns a JSON actions document. That means it runs anywhere that serves HTTPS — a serverless function, an edge function, a container, or the Express app above on a VM. Whatever you pick, four things matter for a voice line:

- **TLS is mandatory.** Sautikit will not POST to plain HTTP.
- **Answer fast.** You have up to the `voice_callback_url` timeout to respond, but a caller hears every millisecond — aim to return in under ~2–3 seconds including any database lookup. Keep a warm connection pool; a fresh DB connection per request adds 200–800 ms of silence.
- **Sit near your callers.** The closest major cloud regions to Kenya are AWS Cape Town (`af-south-1`) and Europe (e.g. `eu-west` / Frankfurt); pick the region option closest to East Africa so the round-trip per keypress stays short.
- **Read the JSON body, dispatch on `Digits`.** The follow-up after a `getDigits` arrives at the *same* URL with a `Digits` field — you branch on it, rather than pointing each key at a different endpoint.

### Vercel

A Vercel Function fits cleanly — and if your marketing site already lives on Vercel, it is one project to operate. This App Router route handler serves the menu and its `Digits` follow-up from one URL:

```js
// app/api/voice/route.ts — Sautikit posts the call event here as JSON
export async function POST(req) {
  const body = await req.json();
  const digit = body.Digits ?? ""; // present on the getDigits follow-up

  if (!digit) {
    // First hit: play the menu and collect one key.
    return Response.json({
      actions: [
        { say: { text: "Karibu. Press 1 for payments, 2 for an agent, 0 to repeat.", language: "en-KE" } },
        { getDigits: { numDigits: 1, timeout: 8, finishOnKey: "" } },
        { say: { text: "We did not get a selection. Kwaheri.", language: "en-KE" } },
        { hangup: {} },
      ],
    });
  }

  // Follow-up hit: branch on the pressed key.
  const target = { "1": "/api/payments", "2": "/api/agent" }[digit] ?? "/api/voice";
  return Response.json({ actions: [{ redirect: { url: `https://${req.headers.get("host")}${target}` } }] });
}
```

Set `SAUTIKIT_API_KEY` in the project's environment variables, and deploy the function to a region close to East Africa. Fluid Compute keeps instances warm, which matters here — a cold start is dead air on a live call.

### Supabase

A Supabase Edge Function is the natural pick if the IVR reads balances or transactions straight from Supabase Postgres — co-locating the function with the database shaves the per-branch lookup. It is Deno, but the shape is identical:

```js
// supabase/functions/voice/index.ts — deploy with `supabase functions deploy voice`
Deno.serve(async (req) => {
  const body = await req.json();
  const digit = body.Digits ?? "";

  if (!digit) {
    return Response.json({
      actions: [
        { say: { text: "Karibu. Press 1 for payments, 2 for an agent, 0 to repeat.", language: "en-KE" } },
        { getDigits: { numDigits: 1, timeout: 8, finishOnKey: "" } },
        { say: { text: "We did not get a selection. Kwaheri.", language: "en-KE" } },
        { hangup: {} },
      ],
    });
  }

  const base = new URL(req.url).origin;
  const target = { "1": "/functions/v1/payments", "2": "/functions/v1/agent" }[digit] ?? "/functions/v1/voice";
  return Response.json({ actions: [{ redirect: { url: `${base}${target}` } }] });
});
```

Point your number's `voice_callback_url` at whichever endpoint you deployed. The Express app earlier and both functions above are the same contract — receive JSON, return actions — so you can start on one and move later without touching your call flow.

This IVR handles voice end-to-end. When a caller needs to be reached over SMS, WhatsApp, or USSD, or handed to a full human-agent desk with ticketing, [Helloduty](https://helloduty.com) adds those channels on top of the same Sautikit voice layer.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Point your number's `voice_callback_url` at the `/voice` handler above and dial in to test the tree.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Further reading

- [Voice actions reference](/developers/concepts/voice-actions): `Say`, `GetDigits`, `Dial`, `Record`, `Redirect`, and `Hangup`
- [Calls API reference](/developers/api#calls): call status, events, and CDR fields
- [Pricing: KES rates and number costs](/pricing): inbound and outbound rates, recording storage costs
- [W3C SSML specification](https://www.w3.org/TR/speech-synthesis/): the speech synthesis markup language that underlies the `Say` verb
- [Swahili language and number conventions](https://en.wikipedia.org/wiki/Swahili_language): background on Swahili numeral structure used in the formatter above
