Daraja API · Setup Guide

M-Pesa Callback URLs: Complete Setup Guide

Everything about M-Pesa callback URLs — setup, HTTPS, local testing with ngrok, callback handling, and debugging when things break.

The M-Pesa callback URL is the single most common point of failure in Daraja API integrations. The STK Push initiates fine, the customer pays, but your application never knows because the callback never arrives. This guide covers callback setup, HTTPS requirements, local development with ngrok, callback handling code, and debugging tips for when callbacks silently fail.

What is a Callback URL?

A callback URL is a public endpoint on your server that Safaricom calls to deliver transaction results. The flow:

  1. Your server initiates an STK Push (C2B), B2C, or B2B request
  2. Customer authorizes (or doesn't) on their phone
  3. Safaricom processes the transaction
  4. Safaricom POSTs the result to your callback URL as JSON
  5. Your callback handler updates the order/transaction status

Without a working callback, your application never knows what happened. Customer paid? You don't know. Customer cancelled? You don't know. The order stays stuck in "pending" forever.

Types of Callback URLs in Daraja

  • CallBackURL — STK Push (C2B online) result notifications
  • ResultURL — B2C and B2B transaction results
  • QueueTimeOutURL — sent when a transaction queues too long without being processed
  • ConfirmationURL — Pay Bill/Till passive listener (when customer pays via STK menu, not your app)
  • ValidationURL — optional pre-validation for ConfirmationURL transactions

Requirements for Callback URLs

  • HTTPS only. HTTP rejected. Use Let's Encrypt (free) or your hosting provider's SSL.
  • Public domain. No localhost, no IP-only addresses, no internal hostnames.
  • Reachable from the public internet. No firewalls or VPN-only access.
  • Responds with HTTP 200 within 30 seconds. Slower or non-200 = Safaricom retries (and may eventually give up).
  • Whitelisted in Daraja portal. Some apps require explicit whitelisting per URL.

Setting Callback URLs

For STK Push (C2B online)

Set the URL in each STK Push request payload. Different transactions can use different URLs (useful for routing).

{
  // ... other fields
  "CallBackURL": "https://yourdomain.com/api/mpesa/stk/callback",
  "AccountReference": "ORDER-123",
  // ...
}

For B2C

Both ResultURL and QueueTimeOutURL must be specified per request:

{
  // ... other fields
  "QueueTimeOutURL": "https://yourdomain.com/api/mpesa/b2c/timeout",
  "ResultURL": "https://yourdomain.com/api/mpesa/b2c/result",
  // ...
}

For C2B passive listening (Pay Bill/Till)

Register URLs once via the Register URL endpoint. They apply to all transactions on that ShortCode going forward:

POST https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl
{
  "ShortCode": "YOUR_SHORTCODE",
  "ResponseType": "Completed",
  "ConfirmationURL": "https://yourdomain.com/api/mpesa/c2b/confirm",
  "ValidationURL": "https://yourdomain.com/api/mpesa/c2b/validate"
}

Testing Callbacks Locally with ngrok

You can't test callbacks against localhost:3000. Use ngrok to expose your local server:

# Install ngrok (if not already): brew install ngrok or download

# Start your local server
npm run dev  # listening on port 3000

# In another terminal, expose it
ngrok http 3000

# ngrok prints something like:
# Forwarding https://abc-12-34-56-78.ngrok-free.app -> http://localhost:3000

# Use this as your callback URL:
# https://abc-12-34-56-78.ngrok-free.app/api/mpesa/callback

Free ngrok URLs change every restart. Paid plans ($8/month) give you a stable subdomain. Cloudflare Tunnel is a free alternative with stable subdomains.

Sample Callback Handler (Next.js API Route)

// app/api/mpesa/stk/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const cb = body?.Body?.stkCallback;

    if (!cb) {
      // Always 200 even on malformed — prevent retries
      return NextResponse.json({ received: true });
    }

    const checkoutRequestId = cb.CheckoutRequestID;
    const resultCode = cb.ResultCode;
    const order = await db.order.findByCheckoutId(checkoutRequestId);

    if (!order) {
      console.error('No order for', checkoutRequestId);
      return NextResponse.json({ received: true });
    }

    // Idempotency check
    if (order.status === 'paid') {
      return NextResponse.json({ received: true });
    }

    if (resultCode === 0) {
      // Successful payment
      const items = cb.CallbackMetadata?.Item ?? [];
      const receiptNumber = items.find(i => i.Name === 'MpesaReceiptNumber')?.Value;
      const amount = items.find(i => i.Name === 'Amount')?.Value;
      await db.order.markPaid(order.id, { receiptNumber, amount });
    } else {
      // Failed — record the reason
      await db.order.markFailed(order.id, { resultCode, resultDesc: cb.ResultDesc });
    }

    return NextResponse.json({ received: true });
  } catch (err) {
    console.error('Callback error:', err);
    // Still 200 — Safaricom will retry if we don't
    return NextResponse.json({ received: true });
  }
}

Debugging When Callbacks Don't Arrive

Run this checklist in order:

  1. Test the URL is reachable: curl -X POST https://yourdomain.com/api/mpesa/stk/callback -d '{}' from outside your network. Should return 200.
  2. Check SSL is valid: curl -v https://yourdomain.com/api/mpesa/stk/callback — look for SSL handshake errors.
  3. Verify in Daraja portal: some apps need callback URLs whitelisted in the "Edit App" section.
  4. Check the STK initiation response: if ResponseCode"0", the request failed before reaching the customer — no callback expected.
  5. Server logs during transactions: add logging at the start of your callback handler. If logs are empty, requests aren't arriving — likely network/DNS issue.
  6. Test with sandbox callback inspector: Daraja portal has a callback testing tool that sends sample payloads to your URL.

Common Mistakes

  • HTTP instead of HTTPS: Most common. Always use https://.
  • URL changes without updating Daraja: Restart ngrok? Update the URL in your STK Push payload too.
  • Slow callback handler: Doing heavy database operations or external API calls inside the handler. Acknowledge with 200 first, process async.
  • No idempotency: Safaricom may retry. Always check if order is already processed.
  • Hardcoded production URLs: Use environment variables.
  • Missing CORS or middleware blocking: Some Next.js / Express middleware blocks POST without specific headers. Verify your route accepts the Daraja content-type.

Frequently Asked Questions

What is an M-Pesa callback URL?+

A callback URL is a public HTTPS endpoint on your server that Safaricom calls to notify your application of the result of an M-Pesa transaction. After a customer enters their PIN (or the time runs out), Safaricom POSTs the result to your callback URL — that's how your system knows whether to mark the order paid or failed.

Does an M-Pesa callback URL have to be HTTPS?+

Yes — Safaricom rejects callback URLs that aren't HTTPS. Use Let's Encrypt for free SSL, or your hosting provider's SSL. localhost URLs also fail — you need a publicly accessible domain.

How do I test M-Pesa callbacks during development?+

Use ngrok or Cloudflare Tunnel to expose your local development server with a public HTTPS URL. Update your sandbox callback URL to the ngrok URL each time it changes (free ngrok URLs change on restart — paid plans give stable URLs).

What's the difference between CallBackURL, ResultURL, and QueueTimeOutURL?+

CallBackURL is for STK Push (C2B). ResultURL is for B2C and B2B success notifications. QueueTimeOutURL receives notifications when transactions queue too long without processing. C2B regular (Pay Bill/Till listening) uses ConfirmationURL and ValidationURL — different again.

Can I have different callback URLs per environment?+

Yes — use sandbox URL during development (e.g., dev.yourdomain.com or ngrok URL), production URL on launch (yourdomain.com). Update them in the Daraja portal whenever they change. Some teams use environment variables to switch in code without redeploying.

Need help with M-Pesa integration end-to-end? We do this for every e-commerce build. Get in touch.