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.
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:
It is delivered as 254708374149: E.164 format with the 254 country code but without the + prefix.
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:
// 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
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.
// 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 }}
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:
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:
const SAUTIKIT_API_KEY = process.env.SAUTIKIT_API_KEY;const SAUTIKIT_FROM = process.env.SAUTIKIT_FROM_NUMBER; // your claimed E.164 numberconst 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.
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).
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:
import { createClient } from "redis";const redis = createClient({ url: process.env.REDIS_URL });await redis.connect();const CONCURRENCY_LIMIT = 8; // Leave 2 slots as bufferconst 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 for the human-agent desk and additional channels.
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.