DEV Community

Devalère Kamguia
Devalère Kamguia

Posted on

Eight things I measured about M-Pesa STK Push that the documentation doesn't tell you

I'm building an open-source, self-hosted mobile money gateway with a real double-entry ledger. Adding M-Pesa meant learning Safaricom's Daraja STK Push properly, and I kept a rule while doing it: write down what I observed, mark what I merely modelled, and keep a Still unknown section instead of guessing.

This is what the observing produced. Some of it contradicts what I assumed, and one item contradicts something I had already written down and had to correct.

Everything below is the sandbox. Shortcode 174379, test MSISDN 254708374149, amount 1, runs between 18 and 23 September 2026. I say this up front because it matters for the last item, and because "it worked in the sandbox" is not a claim about production.

1. Your request bodies carry your passkey

This is the one to act on today.

Password = base64(BusinessShortCode + Passkey + Timestamp)
Enter fullscreen mode Exit fullscreen mode

Base64 encodes. It does not hash. The shortcode isn't secret, and the timestamp travels in the same body. So anyone who can read one request body you sent to Safaricom can recover your passkey by decoding one field and removing two known values.

Which means each of these is a copy of the credential: a debug log that dumps request bodies, an APM trace, a reverse proxy with body capture, a support ticket screenshot, a snippet pasted into a forum to ask why a call fails.

And Safaricom issues the passkey. I have found no documented revocation endpoint — so the only response available is to replace it, which means rotation matters more than it would for a credential you can revoke.

Treat the request body as secret-bearing. Redact it in logs at the source, not in the log viewer.

2. 500.001.1001 does not mean "the transaction does not exist"

I wrote that it did. I was wrong, and I only found out because I kept querying.

On 18 September, querying an unrecognised CheckoutRequestID answered HTTP 500, errorCode 500.001.1001, errorMessage: "The transaction does not Exist". Reasonable conclusion, wrong one.

On 23 September, the same code came back four times out of nineteen queries for a reference that unquestionably existed — interleaved with HTTP 200 answers to the identical request, seconds either side.

So the code distinguishes nothing: not an unknown reference, not a server fault, not a known reference on a bad second. If your client maps it to "no such payment", it will eventually delete or fail a payment that is alive. The only safe mapping is unknown — ask again later.

3. There is no idempotency on AccountReference

Two submissions carrying an identical AccountReference were both accepted, and produced two different CheckoutRequestIDs and two different MerchantRequestIDs.

AccountReference is the only field the caller chooses, and it buys you nothing on retries. If your submit path can run twice — a timeout you retried, a queue that redelivered, a user who double-tapped — you can raise two prompts for one order. Idempotency has to live in your own system, keyed by your own reference, before the call.

4. AccountReference isn't even echoed back

The STK Push response carries MerchantRequestID, CheckoutRequestID, ResponseCode, ResponseDescription, CustomerMessage. The one field you chose is not among them.

So you cannot confirm from the response that Safaricom received the value you sent; you can only correlate on the identifiers it invented. Persist the mapping yourself, before you call.

5. CheckoutRequestID encodes Nairobi local time — don't parse it

ws_CO_180920261803512708374149, for a submission my recorder timestamped 15:03 UTC. The identifier reads 18:03:51 — UTC+3.

Useful to know if you are debugging across time zones and the ids look three hours in the future. Also a good reason to treat it as an opaque string: the day its format changes, anything parsing it breaks, and you gain nothing by parsing it that you don't already have.

6. Safaricom's own example request is type-inconsistent, and copying it matters

In the documented example, BusinessShortCode is a JSON number while Amount and PartyB are strings — including PartyB, which carries the identical value as BusinessShortCode.

A serializer that treats those two fields consistently, precisely because they hold the same number, will produce a request that Safaricom's own example does not match. Whether Daraja rejects it is a separate question; I would not want to find out in production.

7. A callback is not re-delivered

I tested both remaining shapes on 22 September, after an earlier run had only a 200 to look back on:

  • answered the callback with an explicit HTTP 500 → nothing further in the following 31 minutes;
  • let the delivery fail outright, receiver unreachable → nothing further in the following 44 minutes.

Neither was retried, at least not within an hour.

If your design assumes Safaricom will try again while your service restarts, that assumption has not been observed to hold. A payment whose callback you dropped needs you to go and ask — a reconciler that queries pending payments, not a hope that the notification comes back.

8. ResultCode is stable; ResultDesc is not

1037 — "No response from user" — is confirmed across three observations on two days: an 18 September query, a 22 September callback, and that callback's own status query. It answered again on 23 September. The code was identical each time. The prose describing it was not.

Branch on ResultCode. If you branch on ResultDesc, you have a bug waiting for a wording change you will never be notified about.

And 4999 — "The transaction is still under processing", HTTP 200 — is genuinely in flight, not an error. Seen once, on 23 September.

What I still don't know, and why I can't find out

Two members of a vocabulary are not the vocabulary. 1037 and 4999 are confirmed; every other ResultCode, and what an unrecognised one looks like, is unknown to me.

Worse, I have never seen a successful payment. The sandbox payer never answers the prompt, so every observation above is the timeout path. What a success callback actually carries — CallbackMetadata, the receipt number, the payer MSISDN's shape — I have no data on at all. My simulator's success path is modelled, and it is labelled as modelled in the repository for exactly that reason.

Daraja production needs a Kenyan shortcode, which needs a Kenyan registered business. I have neither, so this will not be closed by me.

If you have a production shortcode: one accepted payment and one declined one would close three open questions for everyone reading this, not just for me. The shapes and the codes are what matter, never the values — and please don't paste a request body anywhere, for the reason in item 1.

The ask, with the redaction rules spelled out: https://github.com/Deval123/nkap/issues/233

The full page, every observation dated, with what is modelled marked as modelled: https://github.com/Deval123/nkap/blob/main/docs/providers/m-pesa.md

One caveat on all of it

Daraja 3.0 was announced in late 2025. Nothing above has been re-checked against it, and I have not read a Safaricom page that establishes what changed. If you are on production today and something here reads as outdated, saying so is itself an answer worth having.

Top comments (0)