M-Pesa ResultCode 4999 Is Not a Failure: Handling Undocumented Daraja Codes
The customer's phone buzzes with the M-Pesa confirmation SMS. Money left their account. At almost the same second, your application sends them an email saying the payment failed and asking them to try again. Being a reasonable person, they try again.
Now you have two payments, one order, an angry customer, a refund to process manually, and a support thread that will take longer to resolve than the feature took to build. Somewhere in your logs is a single line explaining all of it:
"ResultCode": 4999
The bug is not 4999. The bug is the else branch in your code that treats every value that is not zero as a failure. Safaricom's Daraja API does not publish an exhaustive list of result codes, and 4999 in particular appears in no official documentation I can find. If your payment state machine can only survive on codes it already knows about, it will eventually mark a successful payment as failed. It is not a question of whether, only of which Tuesday.
4999 from the STK Push Query endpoint means the transaction has not reached a final state yet. It means ask again, not failed.
- Never write a terminal FAILED status on an unrecognised code. Ever.
- Classify every response into exactly three buckets: settled success, known terminal failure, or unknown, still pending. 4999 is the third bucket, and so is any code you have not explicitly written into your table.
- On the third bucket, keep polling with exponential backoff up to a cap, then hand the transaction to a reconciliation job. Do not fail it.
- Never send a customer a "payment failed" message from a polling result. Send it only from a code you recognise as terminal.
The exact response
You call the STK Push Query endpoint with the CheckoutRequestID you got back from the original push, and get something shaped like this:
{
"ResponseCode": "0",
"ResponseDescription": "The service request has been accepted successsfully",
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": "4999",
"ResultDesc": "..."
}
Note that ResponseCode is 0. That is not the transaction result. ResponseCode tells you the query was accepted. ResultCode tells you about the payment, and conflating the two is its own category of production bug.
You may also see the query fail outright with an HTTP 500 and a body carrying errorCode 500.001.1001 while a transaction is in flight. Be careful with that one: the same error code is reported by other developers for credential problems such as a badly built password. Treat it as unknown and retryable, and verify your shortcode, passkey and timestamp concatenation separately rather than reading it as a transaction verdict.
Why a non-terminal code exists at all
An STK Push is not a request-response transaction. It is a conversation between four parties: your server, Safaricom's API gateway, the M-Pesa core system, and a human holding a phone who may be in a matatu with one bar of signal.
When you initiate the push, Safaricom accepts it and returns immediately. The customer then has around a minute to see the prompt and enter their PIN. Only after that does the core system debit, credit and produce a receipt. The query endpoint asks "what is the state of this conversation right now", and for a genuine window of seconds to minutes, the honest answer is "not finished".
Every payment API has this state. Stripe calls it processing. Daraja expresses it as codes that are not in the documented terminal set, and 4999 is one of them. The failure mode is entirely on our side: we write code that assumes a query returns a verdict, when what it returns is a status.
The three-bucket model
Write this table into your code as data, not as a chain of if-statements. Everything not in it is bucket three.
| ResultCode | Commonly seen meaning | Bucket |
|---|---|---|
0 | Processed successfully | Settled success |
1 | Insufficient balance | Terminal failure |
1032 | Request cancelled by the user | Terminal failure |
1037 | Timeout, the user could not be reached or did not respond | Terminal failure |
1019 | Transaction expired | Terminal failure |
2001 | Invalid initiator information, typically a wrong PIN | Terminal failure |
1001 | Unable to lock subscriber, another transaction is in progress | Retryable, not terminal |
4999 | Still processing | Unknown, stay pending |
| anything else | you do not know | Unknown, stay pending |
The exact ResultDesc strings differ slightly between sandbox and production and have changed over time, so match on the numeric code and log the description rather than parsing it. And be honest about the last two rows: they are the only rows that make the system safe.
Implementing it
Step 1: Separate the payment state from the last result code
A single status column that gets overwritten by whatever the last poll said is the root of the problem. You need both a state and the evidence:
ALTER TABLE payments
ADD COLUMN status text NOT NULL DEFAULT 'PENDING',
ADD COLUMN last_result_code text,
ADD COLUMN last_result_desc text,
ADD COLUMN poll_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN settled_at timestamptz;
status moves only on a code you recognise. last_result_code records every answer, including the ones you do not understand, which is how you discover new codes before they cost you money.
Step 2: Classify, do not branch
SUCCESS_CODES = {"0"}
TERMINAL_FAILURE_CODES = {"1", "1019", "1032", "1037", "2001"}
RETRYABLE_CODES = {"1001", "4999"}
def classify(result_code: str) -> str:
code = str(result_code).strip()
if code in SUCCESS_CODES:
return "SUCCESS"
if code in TERMINAL_FAILURE_CODES:
return "FAILED"
if code in RETRYABLE_CODES:
return "PENDING"
# Unknown code. This is the safe default, and the alert.
log.warning("daraja.unknown_result_code", extra={"result_code": code})
return "PENDING"
The important line is the last one. An unknown code produces a warning and a pending state, never a failure. Route that warning to a dashboard you actually look at; over a few months it is how you learn the parts of the code space Safaricom never wrote down.
Step 3: Poll with backoff, and start late
Do not query immediately after initiating. The customer has not touched their phone yet, and an early query returns nothing useful while burning API quota. Wait, then back off:
DELAYS = [15, 15, 20, 30, 45, 60, 90, 120] # seconds, then give up polling
def poll_schedule(attempt: int) -> int | None:
if attempt >= len(DELAYS):
return None # hand over to reconciliation
return DELAYS[attempt] + random.randint(0, 5) # jitter
Total budget here is around six and a half minutes, which comfortably covers a customer who is slow to find their PIN. The jitter matters more than it looks: without it, a thousand concurrent checkouts poll in lockstep and you rate-limit yourself.
Step 4: Make the callback authoritative and the poll a fallback
The C2B confirmation callback is your primary signal. Polling exists because callbacks get lost behind flaky networks, expired TLS certificates and firewall rules changed by somebody else. Both paths must converge on the same result without double-crediting an order, which means the write has to be idempotent on CheckoutRequestID. I have written about the specific ways this bites in Your M-Pesa Callback Will Fire Twice, and the surrounding integration in Daraja API in Production.
Step 5: Exhausted retries go to reconciliation, not to failure
if poll_schedule(payment.poll_attempts) is None and payment.status == "PENDING":
payment.status = "RECONCILING"
reconciliation_queue.enqueue(payment.id)
# No customer email. No order cancellation. A human or the statement decides.
A payment in RECONCILING is checked against the paybill statement or the transaction status API, and only then settled. M-Pesa Reconciliation That Scales covers how to make that automatic rather than a monthly spreadsheet.
Step 6: Gate customer communication on terminal codes only
The line that caused the double charge was not the classifier. It was an email template fired from a poll result. Only a code in TERMINAL_FAILURE_CODES may trigger a "payment did not go through" message, and the message should name the reason: "you cancelled the request" and "your M-Pesa balance was too low" are actionable, "payment failed" is not.
Verification
Three checks, in order of how much they will tell you.
1. Prove no order can fail on an unknown code. A unit test is the cheapest guarantee you will ever buy:
def test_unknown_code_never_fails_payment():
for code in ["4999", "9999", "", "0.0", "abc", "1234567"]:
assert classify(code) != "FAILED"
2. Audit what has already happened. Run this against production and read the result carefully:
SELECT last_result_code, status, count(*)
FROM payments
WHERE created_at > now() - interval '90 days'
GROUP BY 1, 2
ORDER BY 3 DESC;
Any row where status = 'FAILED' and last_result_code is not in your terminal set is a payment you may have wrongly rejected. Cross-check those against the paybill statement for the same period. In my experience this query is uncomfortable the first time it is run on a system that has been live for a year.
3. Watch the unknown-code counter. After deploying, the warning log should be near zero in steady state. A sudden non-zero rate means Safaricom has started returning something new, and you now find out through a graph instead of through a customer complaint.
What people get wrong
"ResultCode != 0 means failed." This single line is responsible for most double-charge complaints in Kenyan e-commerce. Zero means success. Non-zero means not success yet, which is not the same as failure.
"ResponseCode 0 means the payment worked." ResponseCode is about the HTTP request you just made being accepted. The payment result is ResultCode. I have seen orders marked paid off ResponseCode alone, which means every initiated push became a fulfilled order regardless of whether anybody paid.
"Poll every second so the user gets instant feedback." Tight polling gets you rate-limited, and it maximises how often you observe the non-terminal state you are mishandling. Back off, and show the user an honest "waiting for M-Pesa confirmation" state rather than pretending you have an answer.
"Retry the STK Push if the query does not return success." This is the actual mechanism of the double charge. A second push while the first is unresolved can result in two debits. If you must re-push, do it only after a terminal failure code and only on a fresh idempotency key tied to the order.
"Safaricom's docs will list every code." They will not. 4999 is the clearest example: there is no official entry for it, and the only public description is community-maintained. Design as though the code space is open, because it is.
When it is still broken
- The query keeps returning 4999 for hours. Stop polling and check the paybill statement for the amount and the customer's number in that window. If the money arrived, your
CheckoutRequestIDmapping is wrong, not the transaction. - Queries fail with an HTTP 500 every time, not just during settlement. Rebuild the password: base64 of shortcode plus passkey plus timestamp, in that order, with the timestamp in
yyyyMMddHHmmssand matching the one you send in the body. A mismatch here fails constantly rather than intermittently, which is how you tell the two cases apart. - Callbacks never arrive. Confirm your callback URL is publicly reachable over HTTPS on port 443 with a valid certificate chain, and that it returns a 200 quickly. A slow handler gets retried, which is a different bug wearing the same coat.
- You need this to survive a second provider. If Airtel Money or a card processor is coming, put the classification behind an interface now. Build a Payments Port, Not a Daraja Wrapper covers the shape that survives it.
The general rule, and it outlives Daraja: in any integration where a third party owns the state machine, an unrecognised response is an unknown, never a failure. Failure is a decision, and you should only make it with evidence.
Frequently asked questions
- What does M-Pesa ResultCode 4999 mean?
- It means the transaction has not reached a final state yet, so the STK Push Query endpoint has no verdict to give you. It is not a failure code. Query the same CheckoutRequestID again after a short delay and it will resolve to a real result such as 0 for success or 1032 for a user cancellation. Safaricom does not document 4999, so the only safe handling is to treat it as still pending.
- Why did my customer get charged twice after seeing a payment failed message?
- Almost always because the code treats every ResultCode other than 0 as a failure. When the STK query returns a non-terminal code such as 4999, the application writes a terminal FAILED status and tells the customer to try again, while the original transaction is still settling. The customer initiates a second payment, both succeed, and you owe a refund. Fix it by only settling on codes you explicitly recognise.
- How should I handle a Daraja result code that is not in the documentation?
- Default to pending, log the code at warning level, and keep polling with exponential backoff until your retry budget is exhausted, then hand the payment to a reconciliation queue rather than failing it. Never let an unrecognised code trigger a customer notification or an order cancellation. The warning logs also tell you when Safaricom starts returning something new, before it costs you money.
- What is the difference between ResponseCode and ResultCode in the Daraja STK query?
- ResponseCode tells you whether the API accepted your query request, so a ResponseCode of 0 only means the call itself was well-formed. ResultCode tells you about the payment. Marking an order paid because ResponseCode is 0 means every initiated push becomes a fulfilled order regardless of whether the customer ever entered a PIN.