The missed-call (or "beep") protocol is deeply embedded in Kenyan mobile culture: a user flashes a number to signal "call me back" without spending airtime. This guide builds a reverse-engineered version using Sautikit (detect an incoming call, reject it immediately with Reject, then trigger an outbound callback within 3 seconds) so your platform can offer a "give us a missed call and we'll call you" CTA to users who have no airtime.
In Kenya, airtime is scarce and intermittent for a large segment of the population. A user who wants to reach your business but has no airtime has exactly one option that costs them nothing: let the phone ring once and hang up. This is a "flash" or "beep": the caller signals intent and trusts that the receiving party will call back.
Businesses that understand this pattern, and build for it, tap into a segment of users that SMS and app-based channels cannot reach. Microfinance institutions, agricultural extension services, and health information lines in Kenya have used flash-call callback patterns for years. Building it into your voice product requires exactly three components:
An inbound number that receives the flash call
A webhook handler that rejects the call instantly
An outbound call placed within a few seconds of the rejection
User flashes your number
│
▼
Sautikit receives inbound call (state: ringing)
│
▼
Sautikit POSTs to your voice_callback_url (within ~200ms)
│
▼
Your handler returns Reject verb (immediate response)
│
▼
Sautikit returns SIP 486 to Safaricom (call ends)
│
▼
Your handler enqueues outbound call (async, <3s)
│
▼
Sautikit places outbound call to the caller's number
The critical constraint: your voice_callback_url must respond within 2 seconds. The Sautikit webhook fires when the call is in ringing state, before it is answered. If your handler takes longer than 2 seconds to respond, Sautikit answers the call. Inbound calls are free (KES 0/min), so billing is not the concern, but the call will connect rather than being cleanly rejected. A Reject response that arrives after 2 seconds is too late.
This means the callback queue must be fully asynchronous: the webhook handler must not do the database lookup and outbound call placement synchronously in the request path.
The Reject verb ends the call before it is answered. The reason parameter controls what SIP status code Sautikit returns to the carrier:
import express from "express";const app = express();app.use(express.urlencoded({ extended: true }));// Inbound voice handler: reject immediately and queue callback.app.post("/voice", (req, res) => { const caller = req.body.From || req.body.callerNumber || ""; // Queue the callback BEFORE returning the response. // The response must return instantly; the enqueue is a fast Redis call. if (caller) { enqueueCallbackAsync(caller); } // Return Reject with reason "busy". // This sends SIP 486 to the carrier, which Safaricom displays // as "Call not answered" on the caller's handset, the correct UX // for a callback-request pattern (not "busy" or "rejected"). res.json({ actions: [ { reject: { reason: "busy", }, }, ], });});
The reason: "busy" is important. Sautikit maps this to SIP 486 (Busy Here), which Safaricom's network interprets as a temporary unavailability. The caller's handset displays "Call not answered", which is the correct UX signal for a callback-request flow. Alternatives:
reason value
SIP code
Safaricom display
"busy"
486
"Call not answered"
"rejected"
603
"Number unreachable"
(no reason)
480
"Temporarily unavailable"
"Call not answered" is the closest to the user's mental model of a flash call: they know the line was engaged, not broken.
The callback queue uses Redis for two purposes: deduplication and rate control.
Deduplication: Safaricom retries a missed call up to 3 times within 25 seconds before giving up. This means your voice_callback_url may receive 3 webhook POST requests for the same flash call within a 30-second window. Without deduplication, you would place 3 outbound callbacks.
A Redis SET NX PX 30000 on the caller's E.164 number creates a 30-second deduplication window. The NX flag means "set only if not exists": the first webhook sets the key, subsequent webhooks within 30 seconds find the key already set and skip:
import { createClient } from "redis";import { randomUUID } from "node:crypto";const redisClient = createClient({ url: process.env.REDIS_URL });await redisClient.connect();const DEDUP_KEY_PREFIX = "flash:dedup:";const CALLBACK_QUEUE_KEY = "flash:callback_queue";// Enqueue a callback, deduplicated within a 30-second window.async function enqueueCallbackAsync(callerE164) { const dedupKey = DEDUP_KEY_PREFIX + callerE164; // SET NX PX 30000: set key only if not exists, expire in 30 seconds. // Returns "OK" if the key was set (first call), null if already set (duplicate). const isNew = await redisClient.set(dedupKey, "1", { NX: true, PX: 30_000 }); if (!isNew) { // Duplicate flash call within the 30-second window; skip. return; } // Enqueue the callback for async processing. const callbackJob = { job_id: randomUUID(), caller_e164: callerE164, queued_at: Date.now() / 1000, }; await redisClient.rPush(CALLBACK_QUEUE_KEY, JSON.stringify(callbackJob));}
The 30-second window is calibrated to Safaricom's retry behaviour: Safaricom's network retries a missed call up to 3 times within 25 seconds. A 30-second window absorbs all 3 retries with 5 seconds of margin.
A separate worker process drains the callback queue and places the outbound calls. This keeps the webhook handler lightweight and ensures the callback happens promptly:
// callbackWorker.js, runs as a separate processimport { createClient } from "redis";const redisClient = createClient({ url: process.env.REDIS_URL });await redisClient.connect();const SAUTIKIT_API_KEY = process.env.SAUTIKIT_API_KEY;const SAUTIKIT_FROM = process.env.SAUTIKIT_FROM_NUMBER;const APP_URL = process.env.APP_URL;const CALLBACK_QUEUE_KEY = "flash:callback_queue";// Place an outbound call back to the user who flashed the number.async function placeCallbackCall(callerE164) { const bucket = Math.floor(Date.now() / 1000 / 30); const resp = await fetch("https://api.sautikit.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer " + SAUTIKIT_API_KEY, "Content-Type": "application/json", "Idempotency-Key": `callback:${callerE164}:${bucket}`, }, body: JSON.stringify({ from: SAUTIKIT_FROM, to: [callerE164], voice_callback_url: `${APP_URL}/callback-voice`, }), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`Call failed: ${resp.status} ${text}`); } return resp.json();}async function runWorker() { console.log("Callback worker started"); while (true) { // Block on the queue with a 1-second timeout. const item = await redisClient.blPop(CALLBACK_QUEUE_KEY, 1); if (!item) { continue; } const job = JSON.parse(item.element); const callerE164 = job.caller_e164; // Measure latency from queue time to call placement. const latencyMs = (Date.now() / 1000 - job.queued_at) * 1000; console.log( `Placing callback to ${callerE164} (queue latency: ${latencyMs.toFixed(0)}ms)`, ); try { const result = await placeCallbackCall(callerE164); console.log(`Call placed: ${result.call_id}`); } catch (e) { console.log(e.message); } }}runWorker();
The Idempotency-Key uses a 30-second time bucket (Math.floor(Date.now() / 1000 / 30)). This means multiple callback attempts for the same number within the same 30-second window will be deduplicated by Sautikit even if the worker processes them (belt-and-suspenders alongside the Redis dedup key).
When the user answers your callback, your callback-voice webhook fires. Use this opportunity to personalise the greeting with a caller ID lookup:
// Voice handler for outbound callback calls.app.post("/callback-voice", async (req, res) => { // The number we are calling back is in the 'To' field. const calledNumber = req.body.To || req.body.to || ""; // Look up any stored context for this number. const user = await lookupUserByPhone(calledNumber); const greeting = user ? `Hello ${user.first_name}, thank you for your interest in Acme Finance.` : "Hello, thank you for contacting Acme Finance."; res.json({ actions: [ { say: { text: `${greeting} ` + "How can we help you today? " + "Press 1 for loan information, " + "press 2 to check your account balance, " + "or press 3 to speak with an agent.", language: "en-KE", }, }, { getDigits: { numDigits: 1, timeout: 8000, action: `${APP_URL}/callback-menu`, }, }, { hangup: {} }, ], });});
A publicly advertised flash-call number will eventually attract robodiallers and automated systems that flash it repeatedly. Without rate limiting, your callback queue fills with calls to non-existent or spam numbers.
Implement a per-number rate limit in addition to the 30-second dedup:
const RATE_LIMIT_KEY_PREFIX = "flash:rate:";const MAX_CALLBACKS_PER_HOUR = 3;// Returns true if this number is within the hourly rate limit.async function checkRateLimit(callerE164) { const rateKey = RATE_LIMIT_KEY_PREFIX + callerE164; const count = await redisClient.incr(rateKey); if (count === 1) { await redisClient.expire(rateKey, 3600); // 1-hour window } return count <= MAX_CALLBACKS_PER_HOUR;}
Update enqueue_callback_async to check the rate limit:
async function enqueueCallbackAsync(callerE164) { const dedupKey = DEDUP_KEY_PREFIX + callerE164; const isNew = await redisClient.set(dedupKey, "1", { NX: true, PX: 30_000 }); if (!isNew) { return; // Duplicate within 30 seconds } if (!(await checkRateLimit(callerE164))) { return; // More than 3 callbacks in the past hour } const callbackJob = { job_id: randomUUID(), caller_e164: callerE164, queued_at: Date.now() / 1000, }; await redisClient.rPush(CALLBACK_QUEUE_KEY, JSON.stringify(callbackJob));}
3 callbacks per hour per number is generous for a legitimate user and prohibitive for a robodialler.
Inbound calls are free (KES 0/min), so the inbound flash call costs nothing whether it is answered or rejected. The only cost is the outbound callback.
For a campaign that drives 500 flash calls per day with a 40% conversion rate (users who engage with the callback):
500 flash calls × KES 0 inbound = KES 0
500 callbacks placed × KES 6.00 = KES 3,000/day
200 converted users × (your revenue per conversion) = ...
The economics are favourable for any conversion that generates more than KES 20/user.
When the callback menu routes a caller to "speak with an agent," pair Sautikit with Helloduty: its human-agent desk, SMS, WhatsApp, and USSD channels pick up where the voice callback hands off.