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.
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.
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:
{ "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.
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 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.
-- users table: one row per accountCREATE 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 userCREATE 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 userCREATE UNIQUE INDEX one_primary_per_user ON phone_numbers(user_id) WHERE is_primary = true;
On inbound call, look up by any verified number:
SELECT u.id, u.name, pn.e164, pn.is_primaryFROM users uJOIN phone_numbers pn ON pn.user_id = u.idWHERE 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.
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:
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.
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:
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.
You cannot simulate a real dual-SIM handset from a development environment, but you can simulate the key failure scenarios:
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).
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.
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.