---
title: >-
  Reach zero-airtime users in Kenya: a missed call callback flow that costs them
  nothing
description: >-
  Reject a flash call instantly with the Reject verb, queue an outbound callback
  within 3 seconds, and give zero-airtime users a free way to signal interest.
summary: >-
  The missed-call protocol is embedded in Kenyan mobile culture. This guide
  builds the reverse: reject the flash call instantly, queue an outbound
  callback, all at zero cost for the user.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

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.

## The missed-call UX: why it exists and who uses it

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:

1. An inbound number that receives the flash call
2. A webhook handler that rejects the call instantly
3. An outbound call placed within a few seconds of the rejection

<!-- diagram omitted -->

## The architecture: inbound webhook → immediate reject → outbound call

```
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.

## Implementing instant reject with the Reject verb

The `Reject` verb ends the call before it is answered. The `reason` parameter controls what SIP status code Sautikit returns to the carrier:

```js
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.

## Queuing the callback: preventing duplicate callbacks

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:

```js
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.

## The callback worker: placing the outbound call

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:

```js
// callbackWorker.js, runs as a separate process
import { 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).

## The callback voice handler: personalising the greeting

When the user answers your callback, your `callback-voice` webhook fires. Use this opportunity to personalise the greeting with a caller ID lookup:

```js
// 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: {} },
    ],
  });
});
```

## Rate limiting: preventing abuse from robodialers

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:

```js
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:

```js
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.

## Cost model: KES 0 inbound, KES 3/min outbound

The cost breakdown for a flash-call callback:

| Component | Cost |
|---|---|
| Inbound call (rejected before answer) | KES 0 (inbound is free in any case) |
| Outbound callback (average 2 min call) | KES 6.00 |
| Redis operations | ~KES 0.001 (negligible) |
| **Total per converted flash call** | **KES 6.00** |

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](https://helloduty.com): its human-agent desk, SMS, WhatsApp, and USSD channels pick up where the voice callback hands off.

## 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 inbound number's `voice_callback_url` at the `/voice` handler and deploy the callback worker.

**[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): `Reject`, `Dial`, `Say`, `GetDigits`, and the full verb DSL
- [Quickstart: place your first call](/developers/guides/quickstart-place-a-call): claiming your inbound number and API key
- [Pricing: KES rates and number costs](/pricing): inbound per-minute rate (only billed after answer) and outbound per-minute rate
- [GSMA mobile data research](https://www.gsma.com/): context on data access patterns in Sub-Saharan Africa
- [Safaricom network information](https://www.safaricom.co.ke/): Safaricom call routing and retry behaviour for missed calls
