Daraja STK Push from Android: 10 things the docs will not tell you (with Kotlin)
M-Pesa payments for app developers in Kenya, from someone who has been woken up by a callback at 2am
Contents
Sasa wasee. I wire up M-Pesa for a logistics startup in Nairobi, from Eldoret. Safaricom's Daraja API works, but the docs leave a lot for you to discover at 2am. Here is my list. Maze, read it before you start.
1. Never call Daraja from the app
A consumer key and secret inside an APK is a gift to every bored teenager with apktool. App calls your backend, backend calls Daraja. No exceptions, not even "just for the MVP".
2. Cache the token
The OAuth endpoint gives a token valid for about an hour (expires_in is 3599). Fetch it once, cache it, refresh a few minutes before it expires. Do not request a new token for every payment, you will hit limits at the worst moment.
GET https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials
Authorization: Basic base64(consumerKey:consumerSecret)
3. The password is base64(shortcode + passkey + timestamp)
And the timestamp inside the password must be exactly the same string you send in the Timestamp field. Generate it once per request. I use Nairobi time so my logs make sense:
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
data class StkAuth(val password: String, val timestamp: String)
fun stkAuth(shortCode: String, passkey: String): StkAuth {
val ts = ZonedDateTime.now(ZoneId.of("Africa/Nairobi"))
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
val raw = shortCode + passkey + ts
val password = Base64.getEncoder().encodeToString(raw.toByteArray(Charsets.UTF_8))
return StkAuth(password, ts)
}
4. Phone numbers: 2547XXXXXXXX. No plus, no zero
Users type 0712..., +254712..., 254 712..., and the newer 011 numbers too. Normalise on the backend:
fun normaliseMsisdn(input: String): String? {
val d = input.filter { it.isDigit() }
return when {
d.length == 12 && d.startsWith("254") -> d
d.length == 10 && d.startsWith("0") -> "254" + d.drop(1)
d.length == 9 && (d.startsWith("7") || d.startsWith("1")) -> "254" + d
else -> null
}
}
5. The request
POST https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest
{
"BusinessShortCode": "174379",
"Password": "...",
"Timestamp": "20260904190512",
"TransactionType": "CustomerPayBillOnline",
"Amount": 1,
"PartyA": "254712345678",
"PartyB": "174379",
"PhoneNumber": "254712345678",
"CallBackURL": "https://api.yourdomain.co.ke/mpesa/callback",
"AccountReference": "ORDER-88213",
"TransactionDesc": "Delivery fee"
}
174379 is the public sandbox shortcode. Amount is whole shillings, no cents.
6. A successful response is NOT a successful payment
ResponseCode: "0" only means Safaricom accepted the request and sent the prompt to the phone. Save the CheckoutRequestID. The real result comes later, on your callback.
7. The callback URL must be public HTTPS
For local development use a tunnel. And answer the callback fast: store it, put the heavy work on a queue, return.
8. Callbacks sometimes never arrive
Wueh, this one. Network, their side, your side, a deploy at the wrong second. If you have no callback after about a minute, call the STK Push Query API (/mpesa/stkpushquery/v1/query) with the CheckoutRequestID. Your payment state machine needs a "pending, ask again" state.
9. The result codes you will actually see
0: paid. Celebrate1032: the user cancelled the prompt. Very common, especially when the price surprises them1037: the phone could not be reached, or the user did not respond in time1: insufficient balance2001: in my logs this is almost always a wrong PIN
Show a different message for each one. "Payment failed" for a cancelled prompt makes people think M-Pesa is broken.
10. In the app
Show "Check your phone for the M-Pesa prompt", disable the Pay button, and poll your own backend for the status. Generate the idempotency key when the screen opens, so a nervous second tap cannot start a second payment. Emeka wrote a whole post about this here in Ledger Lines, read it.
Si unaona, it is not hard. It is just undocumented. Swali? Drop it below.
Written by
Brian Kiprono
Android dev, M-Pesa integrations, Daraja API survivor. Eldoret born and based, remote for a Nairobi startup. I run, but not as fast as my village expects 🏃🏾♂️
Get the best of #ledger_lines weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment