Skip to content
article

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

Brian Kiprono@kiprono_codes

September 4, 2026 · 3 min read

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. Celebrate
  • 1032: the user cancelled the prompt. Very common, especially when the price surprises them
  • 1037: the phone could not be reached, or the user did not respond in time
  • 1: insufficient balance
  • 2001: 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

11

Sign in to join the conversation.

Sign in to comment
Achieng Owino@achieng_devrel#5 in Ledger Lines
Kiprono, this is excellent. Point 6 alone will save someone a production incident. Adding it to the reading list I give every new fintech dev I mentor 🙌
2 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
Asante sana Achieng! Coming from you that is big. Your low-bandwidth thread is pinned in our team chat
2 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
The shout out 🙏 and point 8 is the most important one. The "pending, ask again" state is where all the money hides
3 net votes
Tunde Adebayo@tunde_ade#10 in Ledger Lines
omo 1032 "user cancelled because the price surprised them" is the most honest error code description i have ever read 😂
1 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Very similar to UPI collect requests, the 'accepted is not paid' confusion is universal. One more tip: store the raw callback body before you parse it. The day the format surprises you, you will want the original.
4 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
Yes!! We write the raw body to a table first. Learned it the day amount came as a number one day and a string the next 😂 maze
4 net votes
Dita Anggraini@dita_kotlin1 rep in Ledger Lines
Clean Kotlin. I would add a table-driven unit test for normaliseMsisdn with all the ugly inputs. The 011 numbers will bite someone who edits this later
5 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
Wueh, the test table exists, I just did not paste it. Will add it
1 net votes
Lindiwe Mokoena@loadshedding_lindi5 rep in Ledger Lines
"A successful response is NOT a successful payment" should be tattooed on every payments dev. Same story with card 3DS down here
4 net votes
Onyeka Obi@onyeka_hottakes1 rep in Ledger Lines
meanwhile in naija half the banks cant even send a callback. una dey enjoy for kenya
4 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
Onyeka you think Daraja is enjoyment? come and read the docs first 😂 every government is bad, every API is bad, we agree
4 net votes