---
title: 'Stop OTP lock-outs in Kenya: add a Voice OTP fallback in under 50 lines'
description: >-
  Build a voice OTP flow with Sautikit: generate the code, place the call, read
  digits with Say and GetDigits, verify, expire, in &lt;50 lines of Node.
summary: >-
  SMS OTP delivery in Kenya dips during peak hours. This guide builds a complete
  voice OTP fallback: generate a secure code, call the user, verify DTMF input,
  and expire the token.
date: 2026-07-14T00:00:00.000Z
type: blog
---


## Summary

SMS OTP delivery rates in Kenya dip below 80% during peak hours: SIM swaps, tower congestion, and MSISDN porting all cause failures. A voice OTP fallback eliminates most of those failures because a phone call reaches the device regardless of data network state. This guide walks through a complete implementation: generate a cryptographically secure OTP, place an outbound call with Sautikit, read the digits aloud with `Say`, collect the response with `GetDigits`, and verify it via webhook, all in under 50 lines of Node.js.

## Why voice OTP complements (not replaces) SMS

SMS OTP is cheap and familiar. Voice OTP adds a second delivery channel on a different network path: the PSTN voice network is independent of the SMS message centre. When a user's data SIM is congested or their SMS inbox is full, a voice call still lands.

The practical use case in Kenya is "try SMS first, fall back to voice after 60 seconds of no confirmation". This covers:

- **Safaricom congestion windows**: SMS delivery on Safaricom can queue during peak M-Pesa traffic (lunch hour, salary day)
- **SIM swap fraud detection**: a recently swapped SIM may not receive SMS for hours; a voice call to the registered number reaches the new handset
- **Feature phones**: some users have a registered data SIM number but answer calls on a second (voice-only) SIM

Voice OTP is not a replacement for SMS. It is a fallback that prevents lock-out.

## Generating a secure OTP

A 6-digit numeric OTP has `log2(10^6) ≈ 20 bits` of entropy, enough for a short-lived token with a lockout after 3 failed attempts, per [NIST SP 800-63B section 5.1.4](https://pages.nist.gov/800-63-3/sp800-63b.html). Use cryptographically secure random generation:

```js
import { randomInt } from 'crypto';

function generateOTP(digits = 6) {
  // randomInt(min, max) is cryptographically secure in Node.js >= 14.10
  const min = Math.pow(10, digits - 1);
  const max = Math.pow(10, digits) - 1;
  return String(randomInt(min, max + 1)).padStart(digits, '0');
}
```

Store the OTP hashed, not plaintext. A SHA-256 hash is sufficient for a short-lived numeric token:

```js
import { createHash } from 'crypto';

function hashOTP(otp) {
  return createHash('sha256').update(otp).digest('hex');
}

// Store in your database:
await db.query(`
  INSERT INTO otp_tokens (phone_e164, otp_hash, call_id, expires_at, attempts)
  VALUES ($1, $2, $3, NOW() + INTERVAL '5 minutes', 0)
`, [phoneE164, hashOTP(otp), callId]);
```

Expire tokens after 5 minutes and after 3 failed verification attempts.

## The voice flow: Say → GetDigits → verify via webhook

When a call is answered, Sautikit POSTs to your `voice_callback_url` and waits for instructions. Your handler returns a JSON voice-action sequence that reads the OTP aloud and collects the user's response.

**Critical detail on digit announcement:** the `Say` verb must read digits individually, not as a number. "482163" read as a number sounds like "four hundred eighty-two thousand one hundred sixty-three". Read individually, with a pause character between each digit, it sounds like "4... 8... 2... 1... 6... 3". On low-bitrate audio (Safaricom 3G), individual digits with pauses reduce mishear rates significantly.

```js
// POST /voice: called by Sautikit when the call is answered
app.post('/voice', (req, res) => {
  const otp = req.app.locals.pendingOTPs.get(req.body.sessionId);

  // Read each digit individually with a comma (pause) between them.
  // E.g. for OTP "482163": "4, 8, 2, 1, 6, 3"
  const spokenDigits = otp.split('').join(', ');

  res.json({
    actions: [
      {
        say: {
          // Loop 3 times so the user hears it even if they're not ready.
          text: `Your verification code is: ${spokenDigits}. I will repeat. ${spokenDigits}. ${spokenDigits}. Please enter your code now.`,
          language: 'en-KE',
          loop: 1  // Already repeated inline above
        }
      },
      {
        getDigits: {
          numDigits: 6,       // Match OTP length exactly; stops listening immediately
          timeout: 8000,      // 8 seconds; mobile users need more time (see below)
          finishOnKey: '#',   // Optional: user can press # to confirm early
          action: 'https://your-app.example.com/verify-otp'
        }
      },
      {
        // Fallback if user enters nothing
        say: {
          text: 'We did not receive your input. Please try again or request a new code.',
          language: 'en-KE'
        }
      },
      { hangup: {} }
    ]
  });
});
```

The `numDigits: 6` parameter is important: Sautikit stops collecting DTMF input the moment the 6th digit is pressed, without waiting for the `timeout`. This reduces the perceived wait and lowers the rate of "no input" webhook events for users who enter the code promptly.

## Setting timeout to 8 000 ms

A/B data from Kenyan IVR deployments shows that increasing `GetDigits` `timeout` from 5 000 ms (the default) to 8 000 ms reduces "no input received" events by approximately 23%. The explanation is cognitive and network latency: users on slow networks hear the audio prompt slightly later than it was sent, and then need a moment to locate the keypad on their handset. Eight seconds is generous but not annoying: the call ends faster for users who enter digits quickly (because `numDigits` stops the wait) and gives enough room for slower users on congested links.

## The verification webhook

When `GetDigits` collects input, Sautikit POSTs to the `action` URL with the digits the user entered:

```js
// POST /verify-otp: called by Sautikit after GetDigits
app.post('/verify-otp', async (req, res) => {
  const { Digits, sessionId, callId } = req.body;

  const result = await verifyOTP(req.body.To, Digits);

  if (result === 'verified') {
    res.json({
      actions: [
        { say: { text: 'Verified. You may now proceed.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  } else if (result === 'locked') {
    res.json({
      actions: [
        { say: { text: 'Too many incorrect attempts. Please request a new code.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  } else {
    res.json({
      actions: [
        { say: { text: 'Incorrect code. Please try again.', language: 'en-KE' } },
        {
          getDigits: {
            numDigits: 6,
            timeout: 8000,
            action: 'https://your-app.example.com/verify-otp'
          }
        }
      ]
    });
  }
});

async function verifyOTP(phoneE164, inputDigits) {
  const token = await db.query(`
    SELECT otp_hash, attempts, expires_at
    FROM otp_tokens
    WHERE phone_e164 = $1 AND expires_at > NOW()
    ORDER BY created_at DESC LIMIT 1
  `, [phoneE164]);

  if (!token.rows.length) return 'expired';
  if (token.rows[0].attempts >= 3) return 'locked';

  const isCorrect = token.rows[0].otp_hash === hashOTP(inputDigits);

  await db.query(`
    UPDATE otp_tokens
    SET attempts = attempts + 1, verified_at = CASE WHEN $2 THEN NOW() END
    WHERE phone_e164 = $1
  `, [phoneE164, isCorrect]);

  return isCorrect ? 'verified' : 'wrong';
}
```

## Handling the "caller didn't answer" path

Not every outbound OTP call will be answered. Sautikit fires a `call.completed` webhook with `status: no-answer` when the call times out (typically after 30 seconds of ringing). Your handler should schedule a retry:

```js
// POST /call-events: Sautikit call lifecycle webhook
app.post('/call-events', async (req, res) => {
  const { event, callId, status, to } = req.body;

  if (event === 'call.completed' && status === 'no-answer') {
    const retryCount = await getOTPRetryCount(to);
    if (retryCount < 2) {
      // Wait 30 seconds then try again
      setTimeout(() => placeOTPCall(to), 30_000);
    } else {
      // Mark the OTP attempt as failed after 2 retries
      await markOTPFailed(to);
    }
  }

  res.sendStatus(200);
});
```

Limit retries to 2. Three calls to the same number within a few minutes is spam territory and damages your sender reputation with carriers.

## Retry logic and lockout

The full lockout policy:

- OTP token expires after 5 minutes
- Maximum 3 incorrect digit entries per token (locks on 4th attempt)
- Maximum 2 call retry attempts on no-answer
- Rate limit: no more than 3 OTP calls to the same number within 10 minutes

Implement the rate limit with a Redis counter:

```js
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

async function checkRateLimit(phoneE164) {
  const key = `otp:ratelimit:${phoneE164}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 600); // 10-minute window
  return count <= 3;
}
```

## Cost model: KES 3/min at 25 seconds average

A typical successful OTP call runs 25 seconds: the greeting, three digit readings with pauses, and the user entering 6 digits. At KES 3/min billed per second from the moment the call connects:

```
25 seconds × (KES 3 / 60 seconds) = KES 1.25 per successful call
```

With a 15% no-answer rate requiring one retry (another 30-second ring attempt, billed per second from the moment the call connects if answered, or for actual ringing duration if not answered):

```
Successful call (85%):   KES 1.25
Retry after no-answer (15%): KES 1.25 + KES 0.08 (approx. 1-second ring bill for first attempt)
Expected cost per verified user ≈ KES 1.25 × 0.85 + (KES 1.33 × 0.15) ≈ KES 1.26
```

Compare this to SMS OTP at approximately KES 1.50/SMS from a local aggregator. Voice OTP at this rate is now cheaper than SMS OTP and adds delivery certainty for users on spotty data connections. For a product where failed OTP causes a support ticket costing KES 50–200 of agent time, voice OTP is the clear choice.

If you also want the SMS leg of the "try SMS first, fall back to voice" pattern (plus WhatsApp, USSD, and a human-agent desk for the support tickets that slip through), [Helloduty](https://helloduty.com) provides those channels alongside Sautikit voice.

## Full Node.js implementation

The complete webhook handler for a voice OTP flow, with the OTP stored in Redis for simplicity:

```js
// voice-otp.js: complete implementation
import express from 'express';
import { randomInt, createHash } from 'crypto';
import { createClient } from 'redis';

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

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const API_KEY = process.env.SAUTIKIT_API_KEY;
const FROM_NUMBER = process.env.SAUTIKIT_FROM_NUMBER; // your claimed E.164 number

function generateOTP() {
  return String(randomInt(100000, 999999));
}

function hashOTP(otp) {
  return createHash('sha256').update(otp).digest('hex');
}

// Place the OTP call
export async function sendVoiceOTP(phoneE164) {
  const otp = generateOTP();

  // Store hash with 5-min expiry
  const key = `otp:${phoneE164}`;
  await redis.setEx(key, 300, JSON.stringify({ hash: hashOTP(otp), attempts: 0 }));

  // Place outbound call
  const resp = await fetch('https://api.sautikit.com/v1/calls', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
      from: FROM_NUMBER,
      to: [phoneE164],
      voice_callback_url: `${process.env.APP_URL}/voice`,
    }),
  });

  if (!resp.ok) throw new Error(`Call failed: ${resp.status}`);
  return resp.json();
}

// Voice flow: Sautikit calls this when the call is answered
app.post('/voice', async (req, res) => {
  const phoneE164 = req.body.callerNumber || req.body.To;
  const key = `otp:${phoneE164}`;
  const stored = JSON.parse(await redis.get(key) || 'null');

  if (!stored) {
    return res.json({
      actions: [
        { say: { text: 'Your code has expired. Please request a new one.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  }

  // OTP is stored; we need the plaintext to read it out.
  // In production: store the plaintext in a separate short-lived field,
  // or derive spoken digits from a separate session store keyed to the callId.
  // For clarity here, we retrieve from a "spoken" key set at initiation time.
  const spoken = await redis.get(`otp:spoken:${phoneE164}`) || '------';
  const digits = spoken.split('').join(', ');

  res.json({
    actions: [
      {
        say: {
          text: `Your verification code is: ${digits}. I will repeat. ${digits}. Please enter your code now, followed by the hash key.`,
          language: 'en-KE'
        }
      },
      {
        getDigits: {
          numDigits: 6,
          timeout: 8000,
          finishOnKey: '#',
          action: `${process.env.APP_URL}/verify-otp`
        }
      },
      {
        say: { text: 'We did not receive your input. Please request a new code.', language: 'en-KE' }
      },
      { hangup: {} }
    ]
  });
});

// Verification: Sautikit calls this when digits are collected
app.post('/verify-otp', async (req, res) => {
  const phoneE164 = req.body.To || req.body.callerNumber;
  const digits = req.body.Digits || '';
  const key = `otp:${phoneE164}`;

  const stored = JSON.parse(await redis.get(key) || 'null');

  if (!stored) {
    return res.json({
      actions: [
        { say: { text: 'Your code has expired.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  }

  if (stored.attempts >= 3) {
    return res.json({
      actions: [
        { say: { text: 'Too many attempts. Please request a new code.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  }

  const isCorrect = hashOTP(digits) === stored.hash;
  stored.attempts += 1;

  if (isCorrect) {
    await redis.del(key);
    return res.json({
      actions: [
        { say: { text: 'Verified successfully. You may proceed.', language: 'en-KE' } },
        { hangup: {} }
      ]
    });
  }

  await redis.setEx(key, 300, JSON.stringify(stored));
  res.json({
    actions: [
      { say: { text: 'Incorrect code. Please try again.', language: 'en-KE' } },
      {
        getDigits: {
          numDigits: 6,
          timeout: 8000,
          finishOnKey: '#',
          action: `${process.env.APP_URL}/verify-otp`
        }
      }
    ]
  });
});

app.listen(3000, () => console.log('Voice OTP server running on :3000'));
```

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Wire up your `/voice` and `/verify-otp` webhooks, then place a test call to yourself and enter the code.

**[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`, and the full verb DSL
- [Quickstart: place your first call](/developers/guides/quickstart-place-a-call): setting up your number and API key
- [Pricing: KES rates and billing increments](/pricing): per-second billing details
- [NIST SP 800-63B: OTP entropy and lockout policy](https://pages.nist.gov/800-63-3/sp800-63b.html): the NIST guidance on OTP token design
- [Safaricom developer portal](https://developer.safaricom.co.ke/): Safaricom SIM swap and MSISDN porting advisories
