</>CodeWithKarani

Daraja API in Production: STK Push, C2B and B2C With Callbacks That Actually Work

Karani GeoffreyKarani Geoffrey7 min read

Every Kenyan developer eventually gets the same ticket: "add M-Pesa". Then they discover that Daraja is not one payments API. It is three or four unrelated systems sharing a login page, each with its own auth model, its own idea of what a callback looks like, and its own failure modes. STK Push speaks one dialect. C2B speaks another. B2C speaks a third and needs RSA encryption before it will talk to you at all.

My thesis: stop treating Daraja as a payment gateway and start treating each leg as its own state machine with its own persistence. The moment you do that, most of the pain disappears. This article is the build order I use, with real payloads.

Step zero: the OAuth token

Every Daraja call needs a bearer token. You get it with HTTP Basic auth using your Consumer Key and Consumer Secret from the Daraja portal.

import base64, time, requests

BASE = "https://api.safaricom.co.ke"  # sandbox: https://sandbox.safaricom.co.ke

_token_cache = {"value": None, "expires_at": 0}

def access_token(key: str, secret: str) -> str:
    if _token_cache["value"] and time.time() < _token_cache["expires_at"]:
        return _token_cache["value"]

    creds = base64.b64encode(f"{key}:{secret}".encode()).decode()
    r = requests.get(
        f"{BASE}/oauth/v1/generate",
        params={"grant_type": "client_credentials"},
        headers={"Authorization": f"Basic {creds}"},
        timeout=15,
    )
    r.raise_for_status()
    body = r.json()          # {"access_token": "...", "expires_in": "3599"}
    _token_cache["value"] = body["access_token"]
    _token_cache["expires_at"] = time.time() + int(body["expires_in"]) - 60
    return _token_cache["value"]

Two production notes. First, expires_in comes back as a string, not an int. Second, if you run more than one process, cache the token in Redis, not in memory. Daraja will happily rate limit you for hammering the token endpoint on every request, and the symptom is an opaque 404.001.03 rather than a helpful 429.

Leg one: STK Push (M-Pesa Express)

STK Push pushes a PIN prompt to the customer's handset. Endpoint: POST /mpesa/stkpush/v1/processrequest.

from datetime import datetime
from zoneinfo import ZoneInfo

def stk_push(shortcode, passkey, amount, msisdn, account_ref, desc, callback_url, token):
    ts = datetime.now(ZoneInfo("Africa/Nairobi")).strftime("%Y%m%d%H%M%S")
    password = base64.b64encode(f"{shortcode}{passkey}{ts}".encode()).decode()

    payload = {
        "BusinessShortCode": shortcode,
        "Password": password,
        "Timestamp": ts,
        "TransactionType": "CustomerPayBillOnline",  # "CustomerBuyGoodsOnline" for Till
        "Amount": int(amount),          # integer only, no decimals
        "PartyA": msisdn,               # 2547XXXXXXXX
        "PartyB": shortcode,            # store number for Till, paybill otherwise
        "PhoneNumber": msisdn,
        "CallBackURL": callback_url,
        "AccountReference": account_ref[:12],
        "TransactionDesc": desc[:13],
    }
    r = requests.post(
        f"{BASE}/mpesa/stkpush/v1/processrequest",
        json=payload,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    return r.json()

The synchronous response is an acknowledgement, not a payment:

{
  "MerchantRequestID": "29115-34620561-1",
  "CheckoutRequestID": "ws_CO_191220191020363925",
  "ResponseCode": "0",
  "ResponseDescription": "Success. Request accepted for processing",
  "CustomerMessage": "Success. Request accepted for processing"
}

Persist CheckoutRequestID against your order before you return to the browser. It is the only handle you get on this transaction, and the callback arrives on a completely different connection, sometimes before your original HTTP response has even finished writing.

The STK callback

Success looks like this:

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

Failure has no CallbackMetadata key at all:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 1032,
      "ResultDesc": "Request cancelled by user."
    }
  }
}

That asymmetry is the number one cause of 500s on callback endpoints. Parse defensively:

from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/hooks/collections/stk")
async def stk_callback(request: Request):
    body = await request.json()
    cb = body.get("Body", {}).get("stkCallback", {})

    checkout_id = cb.get("CheckoutRequestID")
    result_code = int(cb.get("ResultCode", -1))
    items = {
        i["Name"]: i.get("Value")
        for i in cb.get("CallbackMetadata", {}).get("Item", [])
    }

    enqueue_settlement({
        "checkout_id": checkout_id,
        "result_code": result_code,
        "result_desc": cb.get("ResultDesc"),
        "receipt": items.get("MpesaReceiptNumber"),
        "amount": items.get("Amount"),
        "msisdn": items.get("PhoneNumber"),
        "txn_date": items.get("TransactionDate"),
        "raw": body,
    })

    # Always 200. Always fast. Business logic happens in a worker.
    return {"ResultCode": 0, "ResultDesc": "Accepted"}

Result codes you will meet in week one: 0 success, 1 insufficient funds, 1001 unable to lock subscriber (a concurrent session exists), 1019 transaction expired, 1032 cancelled by user, 1037 DS timeout / phone unreachable, 2001 wrong PIN.

Query, do not guess

If no callback arrives within about 90 seconds, poll POST /mpesa/stkpushquery/v1/query with the same BusinessShortCode, Password, Timestamp generation logic plus CheckoutRequestID. Treat the query result as authoritative over silence. Build this reconciler on day one, not after your first angry customer.

Leg two: C2B v2

C2B is for when a customer pays your Paybill or Till directly from the M-Pesa menu, without your app initiating anything. You register two URLs once, then Safaricom calls you forever.

curl -X POST "https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ShortCode": "600991",
    "ResponseType": "Completed",
    "ConfirmationURL": "https://api.example.co.ke/hooks/collections/confirm",
    "ValidationURL": "https://api.example.co.ke/hooks/collections/validate"
  }'

ResponseType only accepts Completed or Cancelled, and it decides what happens when your validation URL is unreachable. Completed means Safaricom takes the money anyway. Cancelled means the customer's payment is rejected. If your ledger cannot tolerate money arriving that you never approved, choose Cancelled and then make very sure your endpoint has better uptime than your marketing site.

The confirmation payload:

{
  "TransactionType": "Pay Bill",
  "TransID": "UCB030CBG1",
  "TransTime": "20260311161727",
  "TransAmount": "1.00",
  "BusinessShortCode": "600991",
  "BillRefNumber": "account001",
  "InvoiceNumber": "",
  "OrgAccountBalance": "4635316.60",
  "ThirdPartyTransID": "",
  "MSISDN": "bbff37cea44ac0b2d964ee0dfb8d2df8513dc7ba1b36129a929fc3fbd6dd4af4",
  "FirstName": "John"
}

Three things that surprise people. TransAmount is a string, not a number. MSISDN is delivered as a hash on production shortcodes, so any design that assumed you could SMS the payer back from the C2B payload needs rethinking. And OrgAccountBalance is a free running balance of your Paybill float - store it on every row, because it is the cheapest gap detector you will ever get.

Validation, if enabled on your shortcode, expects a synchronous verdict:

{ "ResultCode": "0", "ResultDesc": "Accepted" }

To reject, return C2B00012 (invalid account number) or one of its siblings such as C2B00011 (invalid MSISDN) and C2B00013 (invalid amount).

Leg three: B2C v3

Paying customers out is a different animal because it needs an initiator: a portal user whose password you encrypt with Safaricom's public certificate.

from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_pem_x509_certificate

def security_credential(initiator_password: str, cert_pem: bytes) -> str:
    cert = load_pem_x509_certificate(cert_pem)
    ciphertext = cert.public_key().encrypt(
        initiator_password.encode(), padding.PKCS1v15()
    )
    return base64.b64encode(ciphertext).decode()

Use the sandbox certificate for sandbox and the production certificate for production; they are different files and mixing them yields a generic authentication error. Then:

{
  "OriginatorConversationID": "ord-88421-payout-1",
  "InitiatorName": "apiop_payout",
  "SecurityCredential": "hLQ...==",
  "CommandID": "BusinessPayment",
  "Amount": 500,
  "PartyA": "600991",
  "PartyB": "254712345678",
  "Remarks": "Refund order 88421",
  "QueueTimeOutURL": "https://api.example.co.ke/hooks/payouts/timeout",
  "ResultURL": "https://api.example.co.ke/hooks/payouts/result",
  "Occasion": "refund"
}

POST that to /mpesa/b2c/v3/paymentrequest. CommandID is one of SalaryPayment, BusinessPayment or PromotionPayment. Set OriginatorConversationID yourself to a value derived from your payout ID - it is your idempotency key and the only thing standing between a retry and paying someone twice.

The result callback is shaped nothing like the STK one:

{
  "Result": {
    "ResultType": 0,
    "ResultCode": 0,
    "ResultDesc": "The service request is processed successfully.",
    "OriginatorConversationID": "ord-88421-payout-1",
    "ConversationID": "AG_20190103_00004ad7c21029e28510",
    "TransactionID": "NA30XPKKVCW",
    "ResultParameters": {
      "ResultParameter": [
        { "Key": "TransactionAmount", "Value": 500 },
        { "Key": "TransactionReceipt", "Value": "NA30XPKKVCW" },
        { "Key": "ReceiverPartyPublicName", "Value": "2547XXXXXXX - JOHN DOE" },
        { "Key": "TransactionCompletedDateTime", "Value": "03.01.2019 17:48:32" },
        { "Key": "B2CUtilityAccountAvailableFunds", "Value": 4425.00 },
        { "Key": "B2CRecipientIsRegisteredCustomer", "Value": "Y" }
      ]
    }
  }
}

Note the date format: dd.MM.yyyy HH:mm:ss, not the compact numeric form STK uses. Three legs, three date formats. Normalise at the edge.

The URL rules nobody writes down

  • HTTPS on port 443. Not 8443, not HTTP.
  • Your callback URL must not contain the strings "mpesa", "m-pesa" or "safaricom" in any casing. The registration filter silently drops them.
  • No localhost, no ngrok-style tunnels on production shortcodes, and public request-bin services are blocked.
  • Respond 200 quickly. Slow endpoints get treated as failed deliveries.

Safaricom's Daraja 3.0 rollout from late 2025 modernised the platform (cloud-native, far higher throughput, new fraud and identity APIs) but the transactional contracts above are unchanged. What did change: the V1 processwithdrawal endpoint was retired after 26 October 2025, and C2B v1 register-URL is legacy - use v2.

The build order I recommend

  1. Token cache in Redis with a hard TTL.
  2. An payment_intents table keyed by your own ID, with provider_ref columns for CheckoutRequestID and MpesaReceiptNumber.
  3. Callback endpoints that only write a raw event row and return 200.
  4. A worker that turns raw events into ledger entries under a unique constraint.
  5. A reconciler that queries anything still pending after 90 seconds.

Do those five in that order and the rest of Daraja is just JSON.

#m-pesa#daraja#stk-push#kenya#payments#api
Keep reading

Related articles