Lipa Na M-Pesa Online With Daraja: The Callback Is the Payment, Not the Response
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_credentialswith your key and secret as HTTP Basic auth, to get a token that lasts just under an hour.POST /mpesa/stkpush/v1/processrequestto send the PIN prompt. AResponseCodeof0means accepted for processing, not paid.- Your own HTTPS endpoint receives
Body.stkCallback.ResultCode0there, with anMpesaReceiptNumber, 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
Timestampfield 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.
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
| ResultCode | Means | What to show the customer |
|---|---|---|
| 0 | Paid, with a receipt number | Confirm the order |
| 1 | Insufficient balance | Ask them to top up and retry |
| 1032 | Cancelled by the user | Offer to send the prompt again |
| 1037 | No response from the phone, prompt timed out | Retry, and check the number |
| 2001 | Wrong PIN entered | Send 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.