---
title: >-
  Stop dual-SIM verification failures: voice apps that work for Kenyan users
  with two numbers
description: >-
  Kenya has >40% dual-SIM device penetration. Learn how to handle OTP
  mismatches, caller ID breaks, and multi-number schemas without failing users.
summary: >-
  When a user has Safaricom and Airtel SIMs, your voice OTP may call the wrong
  one. This post covers multi-number schemas, carrier detection from E.164
  prefix, and callback routing by active SIM.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

More than 40% of active mobile devices in Kenya carry two SIMs, according to Communications Authority of Kenya data. This creates a silent failure mode in voice applications: a user registers with their Safaricom number, then answers your call on their Airtel SIM, and your verification flow breaks because the caller ID does not match. This post covers the three dual-SIM failure modes and how to design around each.

## Dual-SIM prevalence in Kenya

The CA Kenya Q4 2025 sector statistics report estimated dual-SIM device penetration at approximately 40–44% of active handsets in Kenya. This is one of the highest rates globally. The pattern is economical: users keep a Safaricom SIM for M-Pesa and calls within Safaricom's dominant network, and an Airtel SIM for data bundles that are competitively priced on the Airtel network.

The key behaviour for voice developers: users actively switch between SIMs for different purposes. The SIM active for a given call is not predictable from the SIM used for registration.

## How dual-SIM breaks voice OTP verification

The most common failure: a user registers with `+254712345678` (Safaricom). Your application places a voice OTP call to `+254712345678`. The user's Safaricom SIM is currently inactive (battery or data handoff). The call rings unanswered. The user gets no OTP.

Alternatively: the user has forwarding enabled to their Airtel SIM `+254105678901`. They answer on the Airtel number. Your IVR plays the OTP. If your verification step matches the `From` caller ID from the inbound call leg to the stored number, the match fails: `+254105678901` does not equal `+254712345678`.

The fix: do not match on caller ID. The OTP itself is the verification factor. A `GetDigits` prompt that reads the code and asks the user to enter it back is caller-ID-agnostic:

```json
{
  "actions": [
    {
      "say": {
        "text": "Your verification code is. 4. 8. 2. 1. That is. 4. 8. 2. 1. Please enter it now followed by the hash key.",
        "language": "en-KE",
        "loop": 2
      }
    },
    {
      "getDigits": {
        "numDigits": 4,
        "timeout": 8000,
        "finishOnKey": "#",
        "action": "https://your-server.example.com/otp/verify"
      }
    }
  ]
}
```

When the user enters the digits, your `/otp/verify` endpoint validates the code against your OTP store, keyed on `session_id`, not on phone number. The verification succeeds regardless of which SIM answered.

## How dual-SIM breaks caller ID matching

Some applications use caller ID to auto-identify a returning user. When a Safaricom subscriber calls your support line from their Airtel SIM, the `callerNumber` in the Sautikit webhook will be their Airtel number. Your database lookup on `callerNumber` returns nothing; the user appears to be an unknown caller.

This breaks automatic account lookup and requires the agent (or IVR) to ask for an account number or name, friction that would have been unnecessary if the user had called from their registered number. If that fallback needs to escalate to SMS, WhatsApp, or a human-agent desk, [Helloduty](https://helloduty.com) adds those channels on top of your Sautikit voice flows.

The correct approach: do not treat a single phone number as a primary key for user identity. Use a many-to-one `phone_numbers` table.

## Multi-number schema pattern

```sql
-- users table: one row per account
CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    email       TEXT UNIQUE,
    created_at  TIMESTAMPTZ DEFAULT now()
);

-- phone_numbers table: multiple numbers per user
CREATE TABLE phone_numbers (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID REFERENCES users(id) ON DELETE CASCADE,
    e164        TEXT NOT NULL,
    carrier     TEXT,             -- 'safaricom' | 'airtel' | 'telkom' | 'unknown'
    is_primary  BOOLEAN DEFAULT false,
    verified_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ DEFAULT now(),
    UNIQUE(e164)
);

-- Enforce at most one primary per user
CREATE UNIQUE INDEX one_primary_per_user
    ON phone_numbers(user_id)
    WHERE is_primary = true;
```

On inbound call, look up by any verified number:

```sql
SELECT u.id, u.name, pn.e164, pn.is_primary
FROM users u
JOIN phone_numbers pn ON pn.user_id = u.id
WHERE pn.e164 = $1 AND pn.verified_at IS NOT NULL;
```

This returns the user regardless of which of their registered SIMs they called from. If the number is not found, prompt the caller to provide their account ID and offer to link the new number.

## Detecting carrier from E.164 prefix

Safaricom and Airtel Kenya numbers follow distinct E.164 prefix patterns. At registration or when a new number appears in an inbound webhook, detect the carrier and store it:

```go
// DetectCarrierKE detects the mobile carrier from a Kenyan E.164 number.
// Returns one of: "safaricom", "airtel", "telkom", "unknown".
func DetectCarrierKE(e164 string) string {
    // Safaricom: +2547xx, specifically 0700–0729, 0110–0119, 0740–0749,
    //            0757–0759, 0768–0769, 0790–0799 among others.
    // Airtel Kenya: +2541xx, covering 0100–0109, 0733, 0736, 0750, 0752, 0753,
    //               0758, 0762, 0771, 0781, 0782, 0783 among others.
    // Telkom Kenya (T-Kash): +25477x, covering 0770, 0771, 0776, 0777
    // Source: CA Kenya number plan (updated Q1 2026)

    if !strings.HasPrefix(e164, "+254") {
        return "unknown"
    }
    suffix := e164[4:] // digits after +254

    switch {
    case strings.HasPrefix(suffix, "7") && isIn(suffix[:3], safaricomPrefixes):
        return "safaricom"
    case strings.HasPrefix(suffix, "1") && isIn(suffix[:3], airtelPrefixes):
        return "airtel"
    case strings.HasPrefix(suffix, "77"):
        return "telkom"
    default:
        return "unknown"
    }
}

var safaricomPrefixes = map[string]bool{
    "700": true, "701": true, "702": true, "703": true, "704": true,
    "705": true, "706": true, "707": true, "708": true, "709": true,
    "710": true, "711": true, "712": true, "713": true, "714": true,
    "715": true, "716": true, "717": true, "718": true, "719": true,
    "720": true, "721": true, "722": true, "723": true, "724": true,
    "725": true, "726": true, "727": true, "728": true, "729": true,
    "110": true, "111": true, "112": true, "113": true, "114": true,
    "115": true, "116": true, "117": true, "118": true, "119": true,
}

var airtelPrefixes = map[string]bool{
    "100": true, "101": true, "102": true, "103": true, "104": true,
    "105": true, "106": true, "107": true, "108": true, "109": true,
    "733": true, "736": true, "750": true, "752": true, "753": true,
}

func isIn(prefix string, set map[string]bool) bool { return set[prefix] }
```

Storing `carrier` at registration time enables carrier-aware routing decisions later: for example, applying longer `GetDigits` timeouts to Airtel numbers in areas with thinner 3G coverage.

## Handling "wrong SIM answered" in callback flows

For missed-call callback flows, the callback must go to the same number the missed call came from, not the stored primary number. If a user flashes your number from their Airtel SIM, the `callerNumber` field in the inbound Sautikit webhook is their Airtel E.164. Call that number back.

Your inbound webhook handler:

```js
import express from "express";

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

app.post("/inbound", (req, res) => {
  const caller = req.body.callerNumber; // The active SIM, use THIS for callback
  // const primary = db.getPrimaryNumber(userId); // DON'T use this for callback

  // Queue the callback to the active SIM, then hang up
  queueCallback(caller); // pass caller, not primary
  res.json({ actions: [{ hangup: {} }] });
});
```

The `callerNumber` field in the Sautikit inbound webhook is the number that dialled you: the SIM that is currently active. It is the only number guaranteed to reach the user at that moment.

## Testing dual-SIM scenarios in development

You cannot simulate a real dual-SIM handset from a development environment, but you can simulate the key failure scenarios:

1. **Wrong-number verification failure**: in your test suite, fire a `POST /v1/calls` with a `from` number that differs from the stored number for the test user. Assert that OTP verification succeeds anyway (because it is code-based, not number-based).

2. **Unknown caller lookup**: fire an inbound webhook payload with a `callerNumber` that is not in your `phone_numbers` table. Assert that your IVR offers the account-ID fallback path rather than erroring.

3. **Carrier detection**: unit-test `DetectCarrierKE` with representative E.164 numbers for each carrier (`+254712345678` for Safaricom, `+254105678901` for Airtel, `+254771234567` for Telkom) and assert the correct carrier label.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Build a caller-ID-agnostic voice OTP flow with `GetDigits` so dual-SIM users verify no matter which SIM answers.

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

## Next steps

- [Number rental and E.164 format requirements](/developers/concepts/numbers)
- [Voice OTP full integration guide](/blog/voice-otp-kenya)
- [Quickstart: place your first call](/developers/guides/quickstart-place-a-call)
- [Pricing and KES wallet](/pricing)
