</>CodeWithKarani

Lipa Na M-Pesa Online With Daraja: The Callback Is the Payment, Not the Response

Karani GeoffreyKarani Geoffrey6 min read

The first M-Pesa integration I reviewed for someone else marked orders as paid the moment Safaricom answered the push request with a 200. It worked in testing, because in testing the developer always entered the PIN. It went to production on a Friday, and by Monday the shop had released goods to people who had pressed cancel, entered the wrong PIN, or simply put the phone in a pocket and let the prompt expire.

So before any code, the one sentence that matters: the response to your STK Push request tells you that a prompt was sent, and nothing else. The callback tells you whether money moved. Everything else in this article is detail around that sentence.

Three calls, in this order:

  • GET /oauth/v1/generate?grant_type=client_credentials with your key and secret as HTTP Basic auth, to get a token that lasts just under an hour.
  • POST /mpesa/stkpush/v1/processrequest to send the PIN prompt. A ResponseCode of 0 means accepted for processing, not paid.
  • Your own HTTPS endpoint receives Body.stkCallback. ResultCode 0 there, with an MpesaReceiptNumber, is the payment.

For anything still pending after two minutes, ask /mpesa/stkpushquery/v1/query rather than waiting.

Getting a token

Create an app on the developer portal and you get a Consumer Key and a Consumer Secret. They are exchanged for a short lived bearer token:

import base64, requests

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

def get_token(key, secret):
    r = requests.get(
        BASE + "/oauth/v1/generate?grant_type=client_credentials",
        auth=(key, secret),
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()
    return data["access_token"], int(data["expires_in"])

The response looks like this:

{"access_token": "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm", "expires_in": "3599"}

Cache it. Requesting a fresh token for every payment is the most common cause of the error everyone meets first:

{"requestId": "11728-2929992-1", "errorCode": "404.001.03", "errorMessage": "Invalid Access Token"}

That error has exactly two causes in practice. Either the token has expired, or you generated it in one environment and used it in the other. Sandbox and production have different credentials, different base URLs and different tokens, and nothing about the error message will tell you which mistake you made. Store the token with its expiry time and refresh it a minute early.

Sending the prompt

The password field is where most first attempts fail. It is not a password you choose. It is the base64 encoding of your shortcode, your passkey and a timestamp, concatenated in that order, and the same timestamp must also be sent as its own field:

from datetime import datetime

def stk_push(token, shortcode, passkey, amount, msisdn, callback_url, reference):
    ts = datetime.now().strftime("%Y%m%d%H%M%S")
    password = base64.b64encode((shortcode + passkey + ts).encode()).decode()
    payload = {
        "BusinessShortCode": shortcode,
        "Password": password,
        "Timestamp": ts,
        "TransactionType": "CustomerPayBillOnline",
        "Amount": int(amount),
        "PartyA": msisdn,
        "PartyB": shortcode,
        "PhoneNumber": msisdn,
        "CallBackURL": callback_url,
        "AccountReference": reference,
        "TransactionDesc": "Order " + reference,
    }
    r = requests.post(
        BASE + "/mpesa/stkpush/v1/processrequest",
        json=payload,
        headers={"Authorization": "Bearer " + token},
        timeout=30,
    )
    return r.json()

Three details that cost people an afternoon each:

  • The phone number is 2547XXXXXXXX, twelve digits, no plus sign and no leading zero. Normalise whatever the customer typed before you send it, because users type all three forms.
  • The amount is a whole number. Send an integer. Cents do not exist here.
  • The timestamp inside the password and the Timestamp field must be the same string. Build them in one place, as above, never twice.

A successful call returns:

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

Save the CheckoutRequestID against your order immediately, before you return anything to the browser. It is the only identifier that will appear in the callback, and if you have not stored it you cannot match the payment to the order when it arrives.

1. Your server asks for a token, gets one valid for about an hour 2. Your server posts the STK Push request 3. Safaricom replies ResponseCode 0 - a prompt is on its way, no money has moved 4. The customer sees a PIN prompt. They pay, cancel, mistype, or ignore it 5. Safaricom posts the result to your callback URL. This is the payment If your server was down at step 5, the order stays pending until you query for it
Steps 3 and 5 are minutes apart and can disagree. Only step 5 is a fact about money.

Receiving the callback

Safaricom posts to the URL you sent, from their own network. That means the URL must be publicly reachable over HTTPS with a valid certificate: no localhost, no private IP, no plain HTTP, no self signed certificate. If you have not set that up yet, a free certificate takes ten minutes. While developing on your laptop, use a tunnel that gives you a public HTTPS hostname, and remember to change the URL before going live. Keep the URL a plain path with no query string, and carry your own reference inside the request instead.

A successful callback body:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220171012106580",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          {"Name": "Amount", "Value": 1500},
          {"Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R"},
          {"Name": "TransactionDate", "Value": 20171116103522},
          {"Name": "PhoneNumber", "Value": 254708374149}
        ]
      }
    }
  }
}

A failed one has no CallbackMetadata at all, only a non-zero ResultCode and a description. Write your parser to expect that, because reaching into CallbackMetadata before checking the result code is how the callback handler throws an exception, returns a 500, and loses the notification.

def handle_callback(body):
    cb = body["Body"]["stkCallback"]
    checkout_id = cb["CheckoutRequestID"]
    order = find_order_by_checkout_id(checkout_id)
    if order is None or order.status == "PAID":
        return {"ResultCode": 0, "ResultDesc": "Accepted"}   # already handled

    if cb["ResultCode"] != 0:
        order.mark_failed(cb["ResultDesc"])
        return {"ResultCode": 0, "ResultDesc": "Accepted"}

    items = {i["Name"]: i.get("Value") for i in cb["CallbackMetadata"]["Item"]}
    order.mark_paid(receipt=items["MpesaReceiptNumber"], amount=items["Amount"])
    return {"ResultCode": 0, "ResultDesc": "Accepted"}

Two rules for this handler. Always answer quickly with a 200 and a small JSON body, and do the slow work afterwards; a callback endpoint that takes eight seconds to send an email is a callback endpoint that times out. And make it idempotent, keyed on CheckoutRequestID, so a repeated delivery does not ship the order twice.

The result codes worth knowing

ResultCodeMeansWhat to show the customer
0Paid, with a receipt numberConfirm the order
1Insufficient balanceAsk them to top up and retry
1032Cancelled by the userOffer to send the prompt again
1037No response from the phone, prompt timed outRetry, and check the number
2001Wrong PIN enteredSend a fresh prompt

Verification

In sandbox, use the test shortcode and the passkey from the portal, and the test MSISDN Safaricom gives you. Then check three things:

# the callback is reachable from outside your network
curl -i -X POST https://yourdomain.co.ke/mpesa/callback \
  -H 'Content-Type: application/json' -d '{"Body":{"stkCallback":{"ResultCode":1032,"ResultDesc":"test","CheckoutRequestID":"nope"}}}'

You want a fast 200 and no traceback in the log. Then run a real sandbox push and confirm a row appears in your payments table with a receipt number. Finally, deliberately let a prompt expire without touching the phone, and confirm your system leaves that order unpaid instead of quietly completing it.

What people get wrong

Trusting the synchronous response. Covered above, and it is the expensive one. ResponseCode: 0 means a prompt was accepted for processing.

Polling your own database while the customer stares at a spinner. That is fine as a user interface, but the source of truth must still be the callback plus the query endpoint, not a timer that gives up and assumes success.

Never implementing the query endpoint. Callbacks are lost. A deploy, a restart, a network blip at the wrong moment, and that notification never arrives. Any request with no callback after two minutes should be checked with /mpesa/stkpushquery/v1/query using the stored CheckoutRequestID, and only then marked failed.

Logging the full request body in production. These payloads contain the customer's phone number and your credentials are one field away. Log identifiers and result codes, not everything.

When it is still broken

  • The prompt never reaches the phone. Check the number format first, then confirm you are pushing from the environment whose shortcode you configured.
  • You get a 200 but no callback ever arrives. Something between Safaricom and your handler is dropping it. Test the URL from a machine outside your network, and check the web server access log rather than the application log.
  • Everything works in sandbox and fails on go live. Every value changes: base URL, key, secret, shortcode, passkey and callback URL. Put them all in configuration, and never let a sandbox default survive as a fallback in code.
  • Amounts do not match. Compare against the callback's Amount, not the one you requested, and reconcile against the official statement at the end of the day. The statement is the accounting truth.

Frequently asked questions

Can the M-Pesa callback URL be an HTTP address or localhost?
No. Safaricom posts the result to your URL from their own network, so it must be a publicly reachable HTTPS address with a valid certificate. During development, expose your local server through a tunnel that gives you an HTTPS hostname, and change the URL before you go live.
What does ResponseCode 0 from the STK Push request actually mean?
It means Safaricom accepted your request and is sending a PIN prompt to the phone. It says nothing about whether the customer entered the PIN, had funds, or paid at all. The result of the payment arrives separately on your callback URL as stkCallback.ResultCode.
What happens if my server is down when the callback arrives?
You lose the notification, and your database will show a payment as pending that the customer has actually paid. That is why you also need the query endpoint: for any request still pending after a couple of minutes, ask Safaricom for the status of that CheckoutRequestID instead of waiting for a retry.
Why do I keep getting 'Invalid Access Token'?
Either the token has expired, or you generated it against one environment and used it against the other. Tokens are short lived and are not interchangeable between sandbox and production, and the credentials that produced them are different too. Cache the token with its expiry and request a new one before it lapses.
#M-Pesa#Daraja#STK Push#Payments#API
Keep reading

Related articles