# M-Pesa and Daraja error codes, decoded

Every Safaricom M-Pesa result code and Daraja API error decoded — cause, fix, and a customer-facing message. Searchable.

M-Pesa reports a problem as a number and gives almost no other information. `resultCode: 1037` is an example. That number means nothing until you decode it.

This page decodes every result code. For each code it gives the meaning, the cause, whether a retry can help, and the message for the customer.

## How to read a failure

A failed payment gives you two fields. The two fields come from two different places:

| Field | Where it comes from |
| --- | --- |
| HTTP status | paylod. `401` wrong API key, `422` wrong body, `502` Daraja refused the request. See [API errors](/docs/api). |
| `resultCode` / `resultDesc` | M-Pesa. The customer cancelled the prompt, or the PIN was wrong, or the handset was off. This page decodes these codes below. |

A `202` and then `resultCode: 1032` is not a bug. The push worked, and the customer pressed Cancel.

## You do not have to implement this table

The [Node SDK](/docs/sdk) decodes result codes offline. It makes no network call. It needs no API key at call time.

```ts title="decode.ts"
import { decodeError } from "@paylod/node";

const err = decodeError(1032);

err.title;            // "Payment cancelled by the customer"
err.category;         // "customer"
err.retryable;        // true
err.customerMessage;  // "Payment cancelled — you can try again whenever you're ready."
```

On a failed payment, `collectAndWait()` returns the same object as `outcome.detail`. A `payment.failed` webhook carries the same object as `event.data.decoded`. The two objects are byte-for-byte identical. The message that you show to a customer is therefore the same for every integration. The SDK also exports the whole catalogue as `ERROR_CATALOG`.

Most integrations do not need the raw catalogue. `outcome.message` is the decoded sentence for the customer. `outcome.retryable` tells you whether a second charge is safe. Use the catalogue below for your records, for your dashboards, and for debug work during an incident.

The [CLI](/docs/cli) decodes the same catalogue in your terminal. Run `paylod errors 1032`. This command is the fastest way to read the meaning of a code during an incident.

> [!warn] **Do not show a failure for the codes `4999` and `500.001.1001`. These two codes mean pending, not failed.** Daraja returns them while the customer still looks at the PIN prompt. They decode with `category: "pending"` and `retryable: false`. Here `retryable: false` does not mean that a retry is pointless. It means that the first prompt is still live, and a second push can **double-charge** the customer. Do not collect again on these two codes. Continue to poll the status. The SDK polls for you.

## The most common codes

| Code | Meaning | What to do |
| --- | --- | --- |
| `0` | Success. | Fulfil the order, but first check the settled `amount`. |
| `1032` | The customer pressed **Cancel** on the prompt. | Offer the customer a retry. This code is not an error in your code. |
| `1037` | Timeout. The prompt did not reach the handset, or the customer ignored it. | Retry. The handset was off, or out of coverage, or the customer did not answer. |
| `2001` | Wrong M-Pesa PIN. | Ask the customer to try again. |
| `1` | Insufficient balance. | Tell the customer that the M-Pesa balance is too low. |
| `4999` | **Not a failure.** The customer did not enter the PIN yet. | Continue to poll. Never collect again: the prompt is still live, and a second push can double-charge the customer. |

The catalogue below contains every other code. It contains every Daraja API error, every C2B result code and every B2C result code. You can search it.

## Full catalogue

### STK Push result

| Code | Meaning | Cause | Fix | Retryable |
| --- | --- | --- | --- | --- |
| `0` | Success | The STK Push transaction completed and the customer's payment was received successfully. | Treat the transaction as paid, read the receipt from CallbackMetadata, and fulfil the order. | No |
| `1` | Insufficient M-Pesa balance | The customer does not have enough M-Pesa balance (including any available Fuliza overdraft) to cover the amount. | Nothing to fix on your side — ask the customer to top up their M-Pesa and try the payment again. | Yes |
| `17` | M-Pesa system internal error | Safaricom's system (or the receiving shortcode / Party B) was temporarily unable to process the transaction — a transient M-Pesa-side error, not a problem with your request. | This is transient, but it is NOT proven that no charge was raised. Confirm the payment's final state with GET /status/:id (or the webhook) before charging again; only start a new attempt with a NEW idempotency key once you have confirmed nothing moved. | No |
| `26` | M-Pesa system busy | M-Pesa is under high load and rejected the request because the system was busy. This is an M-Pesa-side condition, not your configuration. | Back off, then confirm the payment's final state with GET /status/:id (or the webhook) before charging again — a busy-system rejection is not proof no charge was raised. Only start a new attempt with a NEW idempotency key once you have confirmed nothing moved. | No |
| `1001` | A transaction is already in process for this number | M-Pesa could not lock the subscriber because the customer's line already has an active USSD/M-Pesa session or an in-flight transaction, so a new STK push cannot start. | Ask the customer to finish or dismiss any open M-Pesa/USSD prompt, then wait 1-3 minutes. Do NOT blind-retry: the in-flight transaction may be your own earlier push, and charging again could double-charge. Confirm the outcome of the existing payment first. | No |
| `1019` | Transaction expired | The transaction exceeded its allowed processing window and M-Pesa expired it. Daraja says only 'Transaction expired.' and does not say why, so we do not guess — the usual reason is the customer taking too long to act on the prompt. Terminal: no money moved. | Let the customer re-initiate the payment and prompt them to approve promptly. | Yes |
| `1025` | Error sending the STK prompt | M-Pesa could not send the STK prompt — usually a transient system error, or the request message (TransactionDesc) exceeded the 182-character limit. | Keep TransactionDesc within 182 characters. Before charging again, confirm the payment's final state with GET /status/:id (or the webhook) — a send failure is not proof no charge was raised. Only start a new attempt with a NEW idempotency key once you have confirmed nothing moved. | No |
| `9999` | Error sending the STK prompt | The push could not be delivered — commonly a transient M-Pesa error, or the request message exceeds the 182-character limit. | Shorten the request fields (especially TransactionDesc) to stay within 182 characters. Before charging again, confirm the payment's final state with GET /status/:id (or the webhook) — a delivery failure is not proof no charge was raised. Only start a new attempt with a NEW idempotency key once you have confirmed nothing moved. | No |
| `1032` | Payment cancelled by the customer | The customer received the STK prompt on their phone but pressed Cancel instead of entering their M-Pesa PIN. No money moved. | Nothing is wrong with your setup — offer a clear retry button so the customer can try again. | Yes |
| `1037` | The M-Pesa prompt went unanswered | M-Pesa sent the STK prompt but received no answer before it expired (~60s). Daraja reports this as 'DS timeout user cannot be reached', and has been OBSERVED live returning ResultDesc 'No response from user.' — the code covers BOTH the customer simply not acting on the prompt (the common case: they ignored, missed or dismissed it) AND a genuinely unreachable handset (phone off, out of coverage). Daraja does not tell us which, so neither do we. Terminal: no money moved. | Nothing is wrong with your setup. Offer a retry and ask the customer to keep their phone to hand and enter their PIN when the prompt appears. Only if the SAME number fails this way repeatedly is it worth checking that the line is a reachable Safaricom number. | Yes |
| `2001` | Wrong M-Pesa PIN | The customer entered the wrong M-Pesa PIN when approving the STK prompt. This is a customer input problem, NOT an issue with your credentials or configuration. NOTE Daraja words this confusingly: on an STK callback it sends ResultDesc 'The initiator information is invalid.' — that wording is about the CUSTOMER's PIN, not your initiator credentials. (The same numeric code on a B2C/C2B result genuinely IS an initiator-credential failure; that is a separate entry in this table, disambiguated by family.) | Nothing to change on your side — ask the customer to retry and enter their correct M-Pesa PIN. | Yes |
| `2028` | Payment amount exceeds the M-Pesa limit | The requested amount is above the customer's allowed M-Pesa transaction or wallet limit (per-transaction or daily maximum). | Ask the customer to pay a smaller amount, or split the payment. Confirm the amount is within Safaricom's transaction limits. | No |
| `2029` | Buy Goods till sent as a Paybill request (or vice versa) | The shortcode is a Buy Goods till but the STK request used CustomerPayBillOnline (or a Paybill was sent as CustomerBuyGoodsOnline). Daraja rejects the transaction-type / shortcode mismatch. | Match the transaction type to the shortcode: CustomerBuyGoodsOnline + till number for a till, CustomerPayBillOnline + paybill for a paybill. Fix it in Credentials and retry. | No |
| `4999` | Still waiting for the customer's PIN | The STK prompt is live on the customer's phone and they have not entered their M-Pesa PIN yet. This is NOT a failure — the payment is still in flight and can still succeed. Retrying now would push a SECOND prompt and can double-charge the customer. | Keep polling GET /status/:id (or wait for the webhook). Do NOT retry the charge and do NOT tell the customer it failed — this payment is still live and can still succeed. | No |
| `500.001.1001` | Transaction is still being processed | M-Pesa is still processing this STK Push — the customer may not have entered their PIN yet. This is NOT a failure. (Daraja overloads this code: on a SYNCHRONOUS API call it can instead mean 'merchant does not exist' / 'wrong credentials' — that terminal case is matched on the message, see the api_error entry for the same code.) | Keep polling GET /status/:id (or wait for the webhook). Do NOT retry the charge and do NOT tell the customer it failed — this payment is still live and can still succeed. | No |

### Daraja API error

| Code | Meaning | Cause | Fix | Retryable |
| --- | --- | --- | --- | --- |
| `400.002.02` | Bad Request — invalid amount or shortcode | The synchronous request was rejected as malformed, commonly due to an invalid amount, an invalid BusinessShortCode, or the payload being sent as form data instead of JSON. | Send the body as JSON, and verify the amount (a positive whole-number KES) and BusinessShortCode are valid before sending. | No |
| `400.002.05` | Invalid request payload | The request payload is structurally invalid or missing required fields, so Daraja returns HTTP 400 before processing. | Validate the JSON schema and required parameters against the endpoint spec before sending. | No |
| `400.008.02` | Invalid phone number | The PhoneNumber/PartyA value is not a valid Safaricom MSISDN in the required 2547XXXXXXXX international format. | Normalize the phone number to the 12-digit 2547XXXXXXXX format before sending. | No |
| `401.002.01` | Unauthorized — invalid or expired access token | The OAuth access token is missing, malformed, or expired (tokens have a ~1 hour TTL), returning HTTP 401. | Request a fresh token and send it as an 'Authorization: Bearer <token>' header, refreshing before expiry. | Yes |
| `404.001.03` | Invalid access token | Daraja reports the access token as invalid on HTTP 404; often the token is fine but the shortcode is not authorized/whitelisted for that specific API. | Confirm the token is valid and current, and contact Safaricom to enable/whitelist the API on your production shortcode. | Yes |
| `404.001.04` | Invalid authentication header | The Authorization header is missing or incorrectly formatted, so the request cannot be authenticated (HTTP 404). | Ensure the header is exactly 'Authorization: Bearer <access_token>' with no extra whitespace. | No |
| `Bad Request - Invalid Initiator Information` | Invalid initiator information | For secured APIs (B2C, Reversal, Transaction Status, Account Balance), the initiator username or the RSA-encrypted SecurityCredential is wrong, does not belong to the shortcode, or has expired. | Re-encrypt the initiator password with Safaricom's current RSA public certificate, confirm the initiator name belongs to the shortcode, and re-save it in Credentials. | No |
| `500.001.1001` | Server error — merchant does not exist or insufficient funds | A server-side error (HTTP 500), commonly a nonexistent merchant/shortcode or, for B2C, insufficient funds in the business account. | Verify the shortcode/merchant configuration and business account balance, then retry or contact Safaricom support. | No |
| `500.002.1001` | Service temporarily unavailable / under maintenance | The Daraja service is temporarily unavailable, throttled (spike arrest), or under maintenance, returning HTTP 500. | Implement exponential backoff and retry later; if persistent, check the Safaricom status page or support. | Yes |

### C2B / B2C result

| Code | Meaning | Cause | Fix | Retryable |
| --- | --- | --- | --- | --- |
| `0` | Success / Accepted | The C2B/B2C/Reversal/Transaction Status/Account Balance request was processed successfully (for C2B validation this is the 'Accept' response). | Return ResultCode 0 to accept, and process the result/receipt details from the confirmation callback. | No |
| `2001` | Invalid initiator information | For B2C and other secured APIs, the initiator username / RSA-encrypted SecurityCredential does not match the shortcode or has expired. (Note: on an STK Push callback, ResultCode 2001 means the customer entered the wrong PIN — see the STK result entry.) | Re-encrypt the initiator password with Safaricom's RSA public cert, confirm the initiator belongs to the shortcode, and renew before expiry. | No |
| `C2B00011` | Invalid MSISDN (Reject) | During C2B validation the payer's phone number (MSISDN) is considered invalid by your validation logic. | Return this reject code when the MSISDN is unrecognized, or accept if you do not need to validate the payer number. | No |
| `C2B00012` | Invalid Account Number (Reject) | During C2B validation the account number / bill reference supplied by the payer does not exist in your system. | Return this reject code for unknown account references, or map/create the account before accepting. | No |
| `C2B00013` | Invalid Amount (Reject) | During C2B validation the paid amount fails your rules (for example the wrong amount for the invoice). | Return this reject code when the amount is invalid, or relax the amount check if partial payments are allowed. | No |
| `C2B00014` | Invalid KYC Details (Reject) | During C2B validation the payer's KYC details do not meet your requirements. | Return this reject code when KYC checks fail, otherwise accept the transaction. | No |
| `C2B00015` | Invalid Shortcode (Reject) | During C2B validation the shortcode targeted by the payment is considered invalid. | Return this reject code for an unexpected shortcode, or verify your shortcode configuration. | No |
| `C2B00016` | Other Error (Reject) | A catch-all C2B validation rejection for any other reason not covered by the specific reject codes. | Return this reject code for unclassified validation failures, and log the reason for follow-up. | No |
