McTaba Labs logo
By Bonaventure Ogeto|

M-Pesa Callback URLs and Webhooks: Handling Payment Confirmations

A callback URL is an endpoint on your server that Safaricom hits with a POST request after an M-Pesa transaction completes. You provide this URL when initiating an STK Push, and Safaricom sends the payment result (success, failure, or timeout) to it. Your endpoint must respond with HTTP 200 within 5 seconds or Safaricom treats the delivery as failed.

How M-Pesa callbacks work

When you initiate an STK Push or register for C2B notifications, you tell Safaricom where to send results. That "where" is your callback URL. It is a standard HTTPS endpoint that accepts POST requests with a JSON body.

The flow is asynchronous. Your STK Push request returns immediately with a CheckoutRequestID. The actual payment result arrives later (5 to 60 seconds) as a separate HTTP request from Safaricom to your callback URL.

This means your application needs two things:

  1. A way to send the STK Push request and store the CheckoutRequestID against the order
  2. A separate endpoint that receives the callback, matches it to the stored order, and updates the payment status

If you are used to synchronous payment APIs where the response tells you "paid" or "not paid," this takes some adjustment. With Daraja, the answer comes on a different channel.

Testing callbacks during local development

The biggest hurdle for new developers: Safaricom needs to reach your callback URL over the public internet. Your laptop running on localhost:3000 is not accessible from Safaricom's servers.

The solution is a tunneling tool that creates a public URL pointing to your local server.

Using ngrok:

# Install ngrok
npm install -g ngrok

# Start your server on port 3000
npx ts-node server.ts

# In a second terminal, start the tunnel
ngrok http 3000

ngrok gives you a URL like https://abc123.ngrok-free.app. Use this as your callback URL:

DARAJA_CALLBACK_URL=https://abc123.ngrok-free.app/api/mpesa/callback

Alternatives to ngrok:

  • localtunnel: npx localtunnel --port 3000. Free, no signup required, but less reliable.
  • Cloudflare Tunnel: cloudflared tunnel --url http://localhost:3000. Free tier available, more stable for long sessions.
  • VS Code port forwarding: if you use VS Code, the built-in port forwarding feature creates a public URL from the Ports panel.

Important: on ngrok's free plan, the URL changes every time you restart the tunnel. Update your .env accordingly. Paid plans give you a stable subdomain.

Inspecting callbacks:

ngrok includes a web inspector at http://localhost:4040 that shows every request passing through the tunnel. This is invaluable for debugging. You can see the exact JSON Safaricom sent, the response your server returned, and replay requests.

Building a robust callback handler

A production callback handler needs to do several things correctly. Here is a complete example.

Node.js (Express):

import express from 'express';

const app = express();
app.use(express.json());

// In-memory set for idempotency (use Redis or DB in production)
const processedCallbacks = new Set<string>();

app.post('/api/mpesa/callback', async (req, res) => {
  // Step 1: Acknowledge immediately
  res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });

  // Step 2: Extract the key fields
  const callback = req.body?.Body?.stkCallback;
  if (!callback) {
    console.error('Malformed callback payload');
    return;
  }

  const { CheckoutRequestID, ResultCode, ResultDesc } = callback;

  // Step 3: Idempotency check
  if (processedCallbacks.has(CheckoutRequestID)) {
    console.log(`Duplicate callback for ${CheckoutRequestID}, skipping`);
    return;
  }
  processedCallbacks.add(CheckoutRequestID);

  // Step 4: Process based on result
  if (ResultCode === 0) {
    const items = callback.CallbackMetadata?.Item || [];
    const amount = items.find(
      (i: any) => i.Name === 'Amount'
    )?.Value;
    const receipt = items.find(
      (i: any) => i.Name === 'MpesaReceiptNumber'
    )?.Value;
    const phone = items.find(
      (i: any) => i.Name === 'PhoneNumber'
    )?.Value;

    console.log(
      `Payment confirmed: KES ${amount}, ` +
      `Receipt: ${receipt}, Phone: ${phone}`
    );

    // TODO: Update order in database
    // await db.orders.update({
    //   where: { checkoutRequestId: CheckoutRequestID },
    //   data: { status: 'paid', receipt, paidAt: new Date() },
    // });
  } else {
    console.log(
      `Payment failed: ${ResultDesc} (code ${ResultCode})`
    );
    // TODO: Update order status to 'failed'
  }
});

app.listen(3000);

Python (Flask):

from flask import Flask, request, jsonify
import threading

app = Flask(__name__)
processed_callbacks: set[str] = set()
lock = threading.Lock()

def process_payment(checkout_id: str, data: dict):
    """Handle payment processing in the background."""
    callback = data["Body"]["stkCallback"]
    result_code = callback["ResultCode"]

    if result_code == 0:
        items = callback["CallbackMetadata"]["Item"]
        amount = next(i["Value"] for i in items if i["Name"] == "Amount")
        receipt = next(
            i["Value"] for i in items
            if i["Name"] == "MpesaReceiptNumber"
        )
        print(f"Payment confirmed: KES {amount}, Receipt: {receipt}")
        # TODO: update order in database
    else:
        desc = callback["ResultDesc"]
        print(f"Payment failed: {desc} (code {result_code})")

@app.route("/api/mpesa/callback", methods=["POST"])
def mpesa_callback():
    data = request.get_json()
    checkout_id = (
        data.get("Body", {})
        .get("stkCallback", {})
        .get("CheckoutRequestID", "")
    )

    with lock:
        if checkout_id in processed_callbacks:
            return jsonify({"ResultCode": 0}), 200
        processed_callbacks.add(checkout_id)

    # Process asynchronously
    thread = threading.Thread(
        target=process_payment, args=(checkout_id, data)
    )
    thread.start()

    return jsonify({"ResultCode": 0, "ResultDesc": "Accepted"}), 200

if __name__ == "__main__":
    app.run(port=3000)

Key patterns in this code:

  • Respond first, process later: the HTTP 200 response goes back to Safaricom before any database work happens. This prevents timeouts.
  • Idempotency: the CheckoutRequestID is checked against a set of already-processed callbacks. In production, use a database unique constraint or Redis set instead of an in-memory set.
  • Defensive parsing: optional chaining and null checks prevent crashes when Safaricom sends an unexpected payload shape.

Timeout behavior and missed callbacks

Safaricom gives your callback endpoint roughly 5 seconds to respond with HTTP 200. If your server takes longer, or returns an error, or is unreachable, the callback delivery is considered failed.

What happens when a callback is missed:

  • Safaricom may retry the callback once. Do not rely on this. The retry behavior is not formally documented and varies.
  • The payment still happened on M-Pesa's side. The customer was charged. You just did not receive the notification.
  • You need a recovery mechanism.

The recovery mechanism is the STK Push Query endpoint. After initiating an STK Push, set a timer. If no callback arrives within 90 seconds, poll the query endpoint:

// Poll for result when callback is missing
async function pollForResult(checkoutRequestId: string) {
  const maxAttempts = 3;
  const delayMs = 30000; // 30 seconds between polls

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const result = await querySTKStatus(checkoutRequestId);

    if (result.ResultCode !== undefined) {
      // We got a definitive answer
      return result;
    }

    // Wait before next poll
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }

  // After all attempts, mark as uncertain
  return { ResultCode: -1, ResultDesc: 'Status unknown after polling' };
}

In a production system, run this polling logic in a background job queue (Bull, Celery, or a cron job), not in your main request handler.

Production security and reliability

Your callback endpoint is a public URL. Anyone who discovers it could send fake payment confirmations. Here is how to protect it:

IP whitelisting: Safaricom publishes the IP ranges their servers use to send callbacks. Configure your firewall or reverse proxy to only accept requests from those IPs. Check the Daraja documentation for the current list, as it can change.

Verify amounts: always compare the amount in the callback against the amount you expected for that CheckoutRequestID. If someone sends a fake callback claiming KES 50,000 was paid for an order that costs KES 500, your verification should catch it.

// Amount verification
const expectedAmount = await getOrderAmount(checkoutRequestId);
const callbackAmount = items.find(
  (i: any) => i.Name === 'Amount'
)?.Value;

if (callbackAmount !== expectedAmount) {
  console.error(
    `Amount mismatch for ${checkoutRequestId}: ` +
    `expected ${expectedAmount}, got ${callbackAmount}`
  );
  // Do NOT mark order as paid
  return;
}

HTTPS only: production callbacks must use HTTPS. Safaricom requires it, and it prevents anyone from intercepting the callback data in transit.

Logging: log every callback with the full payload, timestamp, and source IP. When a customer disputes a payment, these logs are your evidence.

Alerting: set up alerts for callback failures, high error rates, and unexpected result codes. If your callback endpoint goes down for 10 minutes during a busy period, you could miss dozens of payments.

Frequently Asked Questions

Can I use the same callback URL for STK Push and C2B?
You can, but the payload structures are different. STK Push callbacks have a stkCallback wrapper while C2B notifications have a different format. Using separate endpoints makes your code cleaner and easier to maintain. For example, /api/mpesa/stk-callback and /api/mpesa/c2b-callback.
What if my server is down when the callback arrives?
The payment still happened. The customer was charged. You just missed the notification. Implement the STK Push Query endpoint as a fallback and run a reconciliation job that checks for payments you might have missed. Many production systems run a periodic sweep every 5 to 10 minutes.
Can I use a serverless function as my callback URL?
Yes. AWS Lambda, Google Cloud Functions, Vercel Serverless Functions, and Supabase Edge Functions all work. The key requirement is that the function responds within 5 seconds and is accessible via HTTPS. Cold starts on serverless platforms can sometimes push response times close to that limit, so monitor latency.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.

See Programs

Also available: M-Pesa Integration course