---
title: Confirm every M-Pesa payment by voice within 8 seconds of the transaction
description: >-
  Receive a Daraja IPN, validate it, trigger a Sautikit outbound call, and voice
  back the KES amount and transaction ID within 8s of an M-Pesa payment.
summary: >-
  M-Pesa's CustomerPayBillOnline callback fires the moment a payment clears.
  This guide triggers a Sautikit voice call within 8s, reads the KES amount,
  handles concurrency.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

M-Pesa's `CustomerPayBillOnline` callback fires a JSON webhook the moment a payment clears. Most integrations send an SMS confirmation, but a voice call lands immediately, reads the amount and reference number aloud, and achieves a listen rate well above SMS open rates. This guide shows the exact integration: receive the Daraja callback, validate it, trigger a Sautikit outbound call, and voice back the KES amount and transaction ID within 8 seconds of payment.

## The Daraja IPN payload: what you receive on payment

When a customer pays via M-Pesa Paybill, Safaricom's Daraja platform sends a `CustomerPayBillOnline` callback to your registered IPN URL. The payload looks like this:

```json
{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 1500.00 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "Balance" },
          { "Name": "TransactionDate", "Value": 20191219102115 },
          { "Name": "PhoneNumber", "Value": 254708374149 }
        ]
      }
    }
  }
}
```

Two things to note about the `PhoneNumber` field:

1. It is delivered as `254708374149`: E.164 format with the `254` country code but **without the `+` prefix**.
2. Sautikit's outbound call API requires the `+` prefix in the `to` field.

Getting this wrong is silent: the call attempt will fail with a validation error, or worse, dial a wrong number if the raw digits happen to form a valid E.164 number in another country code.

The normalisation is one line:

```js
// Convert Daraja PhoneNumber (254XXXXXXXXX) to E.164 (+254XXXXXXXXX).
function normalizeE164(phone) {
  // String() + trim strips whitespace and float notation from the raw value.
  let s = String(phone).trim();
  if (!s.startsWith("+")) {
    s = "+" + s;
  }
  return s;
}

// Test:
console.assert(normalizeE164(254708374149) === "+254708374149");
console.assert(normalizeE164("254708374149") === "+254708374149");
console.assert(normalizeE164("+254708374149") === "+254708374149"); // already correct
```

## Validating the Daraja signature before triggering a call

Validate the Daraja callback before acting on it. Safaricom provides an OAuth-based security model: your IPN endpoint should verify that the callback originated from Safaricom's infrastructure.

For a production integration, validate with the Daraja OAuth token. A lightweight approach: verify the payload structure and that `ResultCode === 0` (success) before placing any call. A `ResultCode` other than `0` means the payment was cancelled or failed; do not trigger a confirmation call for failed payments.

```js
// Extract payment fields from a Daraja CustomerPayBillOnline callback.
// Returns null if the payment was unsuccessful or the payload is malformed.
function extractPaymentData(callbackBody) {
  try {
    const stk = callbackBody.Body.stkCallback;
    if (stk.ResultCode !== 0) {
      return null; // Payment failed or was cancelled
    }

    const items = Object.fromEntries(
      stk.CallbackMetadata.Item.map((item) => [item.Name, item.Value]),
    );

    return {
      amountKes: Number(items.Amount),
      receipt: String(items.MpesaReceiptNumber),
      phoneE164: normalizeE164(items.PhoneNumber),
      transactionDate: String(items.TransactionDate),
      checkoutRequestId: stk.CheckoutRequestID,
      businessShortCode: stk.BusinessShortCode,
    };
  } catch {
    return null; // Malformed payload
  }
}
```

## Branching on BusinessShortCode

Organisations with multiple Paybill numbers (for example, a digital lender with a separate collection Paybill and a deposit Paybill) must branch on `BusinessShortCode`. The confirmation message differs depending on which Paybill received the payment:

```js
const PAYBILL_CONFIG = {
  "400200": {
    name: "Acme Loans Repayment",
    messageTemplate: "Loan repayment of {amount} received. Reference {receipt}.",
  },
  "400201": {
    name: "Acme Savings Deposit",
    messageTemplate:
      "Deposit of {amount} credited to your savings account. Reference {receipt}.",
  },
};

function getConfirmationMessage(payment) {
  const shortCode = String(payment.businessShortCode ?? "");
  const config = PAYBILL_CONFIG[shortCode] ?? {
    messageTemplate: "Payment of {amount} received. Reference {receipt}.",
  };
  const amountKes = `KES ${payment.amountKes.toLocaleString("en-KE", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  })}`;
  return config.messageTemplate
    .replace("{amount}", amountKes)
    .replace("{receipt}", payment.receipt);
}
```

## Placing the outbound call with Sautikit's REST API

Once you have validated the payment and built the confirmation message, place the outbound call. The `voice_callback_url` parameter tells Sautikit which URL to call when the recipient answers:

```js
const SAUTIKIT_API_KEY = process.env.SAUTIKIT_API_KEY;
const SAUTIKIT_FROM = process.env.SAUTIKIT_FROM_NUMBER; // your claimed E.164 number
const APP_URL = process.env.APP_URL;

// Place an outbound voice confirmation call. Returns the call_id.
async function placeConfirmationCall(payment) {
  // Store the message keyed to the checkoutRequestId so the voice
  // handler can retrieve it when the call is answered.
  await storePendingMessage(payment.checkoutRequestId, payment);

  const resp = await fetch("https://api.sautikit.com/v1/calls", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SAUTIKIT_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": payment.checkoutRequestId, // prevents duplicate calls
    },
    body: JSON.stringify({
      from: SAUTIKIT_FROM,
      to: [payment.phoneE164],
      voice_callback_url: `${APP_URL}/mpesa-confirm-voice`,
    }),
  });

  if (!resp.ok) {
    throw new Error(`Sautikit call failed: ${resp.status}`);
  }

  const data = await resp.json(); // 202 { call_id, pbx_session_id, status, stream_url }
  return data.call_id;
}
```

Using `payment.checkoutRequestId` as the `Idempotency-Key` means that if Daraja retries the IPN callback (which it does on non-2xx responses), the second call to `placeConfirmationCall` for the same payment will not trigger a duplicate outbound call; Sautikit deduplicates it.

## The voice flow: Say → GetDigits → confirmation acknowledgement

When the customer answers, Sautikit POSTs to your `voice_callback_url`. Read back the payment details:

```js
import express from "express";

const app = express();
app.use(express.urlencoded({ extended: true })); // Sautikit posts form fields

// Voice handler called when the M-Pesa confirmation call is answered.
app.post("/mpesa-confirm-voice", async (req, res) => {
  const callId = req.body.CallId || req.body.sessionId || "";

  // Retrieve the payment data stored during call placement.
  const payment = await getPendingMessageByCall(callId);

  if (!payment) {
    return res.json({
      actions: [
        { say: { text: "Thank you for your payment.", language: "en-KE" } },
        { hangup: {} },
      ],
    });
  }

  const message = getConfirmationMessage(payment);

  return res.json({
    actions: [
      {
        say: {
          text:
            `Hello. This is a payment confirmation from Acme Finance. ` +
            `${message} ` +
            `Press 1 to confirm you have received this notification, ` +
            `or press 2 to speak with an agent.`,
          language: "en-KE",
        },
      },
      {
        getDigits: {
          numDigits: 1,
          timeout: 8000,
          finishOnKey: "#",
          action: `${APP_URL}/mpesa-confirm-response`,
        },
      },
      // If no response, say goodbye
      { say: { text: "Thank you. Goodbye.", language: "en-KE" } },
      { hangup: {} },
    ],
  });
});

app.post("/mpesa-confirm-response", async (req, res) => {
  const digit = req.body.Digits || "";

  if (digit === "1") {
    // Log the acknowledgement
    await logConfirmationAck(req.body.CallId || "");
    return res.json({
      actions: [
        { say: { text: "Thank you for confirming. Goodbye.", language: "en-KE" } },
        { hangup: {} },
      ],
    });
  }

  if (digit === "2") {
    return res.json({
      actions: [
        { say: { text: "Connecting you to an agent.", language: "en-KE" } },
        // Hand off to a flow that places the agent leg.
        { redirect: { url: `${APP_URL}/connect-agent` } },
      ],
    });
  }

  return res.json({
    actions: [
      { say: { text: "Thank you. Goodbye.", language: "en-KE" } },
      { hangup: {} },
    ],
  });
});
```

## Handling the race condition: what if the user is already on a call?

Sautikit will queue the outbound call if the destination number is busy. The call enters `ringing` state and rings when the current call ends. This is the expected behaviour for a payment confirmation; a slight delay is acceptable.

For the caller-already-on-a-call case, the `call.completed` webhook will have `status: busy`. You can choose to retry after 30 seconds or let the Sautikit retry mechanism handle it (if you have configured automatic retry on busy in your outbound call settings).

## Concurrency limit: Redis queue for high-volume Paybills

Sautikit allows a maximum of 10 concurrent outbound calls per account on the default tier. For a fintech processing more than 10 simultaneous M-Pesa payments (common at month-end salary cycles when many customers pay loans simultaneously), you need a queue:

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

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

const CONCURRENCY_LIMIT = 8; // Leave 2 slots as buffer
const QUEUE_KEY = "mpesa_confirmation_queue";
const ACTIVE_CALLS_KEY = "mpesa_active_calls";

// Add a confirmation call to the queue.
async function enqueueConfirmationCall(payment) {
  await redis.rPush(QUEUE_KEY, JSON.stringify(payment));
  await processQueue();
}

// Process queued calls up to the concurrency limit.
async function processQueue() {
  let active = Number((await redis.get(ACTIVE_CALLS_KEY)) || 0);

  while (active < CONCURRENCY_LIMIT) {
    const paymentJson = await redis.lPop(QUEUE_KEY);
    if (!paymentJson) {
      break;
    }

    const payment = JSON.parse(paymentJson);
    await redis.incr(ACTIVE_CALLS_KEY);
    active += 1;

    // Place the call in the background (do not await).
    placeAndTrackCall(payment);
  }
}

// Place the call and decrement the active count when done.
async function placeAndTrackCall(payment) {
  try {
    const callId = await placeConfirmationCall(payment);
    // Poll for completion or use the call.completed webhook to decrement.
    await waitForCallCompletion(callId);
  } finally {
    await redis.decr(ACTIVE_CALLS_KEY);
    await processQueue(); // Process next item in queue
  }
}
```

This pattern ensures that during a month-end surge, confirmation calls are queued and processed at the maximum allowed rate rather than failing with a concurrency error.

If the press-2 path needs to route to a staffed queue with ticketing, or you want to fall back to SMS or WhatsApp confirmations for customers who miss the call, pair Sautikit voice with [Helloduty](https://helloduty.com) for the human-agent desk and additional channels.

## Cost analysis

A typical M-Pesa payment confirmation call runs 20 seconds (greeting, amount, reference, press-1 acknowledgement). At KES 3/min outbound:

```
20 seconds × (KES 3 / 60 seconds) = KES 1.00 per call
```

For a portfolio with 1 000 M-Pesa payments per day, the voice confirmation cost is:

```
1 000 × KES 1.00 = KES 1,000/day = KES 30,000/month
```

At KES 150 average disputed transaction value and a 2% dispute rate, voice confirmations reduce disputes by an estimated 40% (users who acknowledge receipt are less likely to dispute). That is 8 prevented disputes per day × KES 150 = KES 1,200/day in dispute avoidance, nearly offsetting the confirmation call cost.

## Production checklist before going live

Before enabling voice confirmation calls in production:

- [ ] Validate the Daraja `ResultCode === 0` before placing any call
- [ ] Normalise `PhoneNumber` to `+254XXXXXXXXX` before passing to Sautikit
- [ ] Use the `CheckoutRequestID` as the Sautikit `Idempotency-Key`
- [ ] Store pending call context (payment data) keyed to the call ID, not the session
- [ ] Implement the Redis concurrency queue if you expect >10 simultaneous payments
- [ ] Test the "user is on another call" flow in staging
- [ ] Confirm your `voice_callback_url` responds within 2 seconds to avoid call timeout
- [ ] Set up monitoring on the `call.failed` webhook to detect Daraja phone number format issues

## 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 Daraja IPN URL at a handler that validates the callback and calls `POST /v1/calls` to voice back the confirmation.

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

## Further reading

- [Quickstart: place your first call](/developers/guides/quickstart-place-a-call): setting up your number and API key before attempting outbound calls
- [Pricing: KES rates and billing increments](/pricing): outbound rate and billing details
- [Calls API reference](/developers/api#calls): full call object fields and status values
- [Safaricom Daraja CustomerPayBillOnline API](https://developer.safaricom.co.ke/APIs/CustomerPayBillOnline): full IPN payload documentation
- [M-Pesa for developers](https://developer.safaricom.co.ke/): Daraja sandbox and production credentials
