M-Pesa 'Unable to Send STK Prompt' (1025/9999): Check the Length
A customer clicks Pay. Nothing happens on their phone. Your logs show ResultCode 1025, description Unable to Send STK Prompt. Somebody says "Safaricom is having issues again", the retry loop kicks in, and everyone moves on.
Except this particular customer's payment fails every single time, forever, and the ones either side of them work fine. That is not a platform blip. That is a payload your code built and never checked.
1025 has two completely different causes wearing one error code, and the two need opposite responses. One is transient and retrying is correct. The other is deterministic and retrying is pure waste, because the twentieth attempt sends the exact same oversized TransactionDesc as the first.
before you blame Safaricom, measure the length of your TransactionDesc.
- The documented maximum for
TransactionDescis 13 characters. In practice the gateway tolerates far more, and requests start failing with 1025 once the value crosses roughly 182 characters including spaces. - 9999 is the same error as 1025. Handle them identically. Do not write two branches.
- Validate length client-side, before calling Daraja, so an over-long description fails fast with a clear message instead of a mystery system error.
- If the length is fine, then it genuinely can be a temporary M-Pesa system issue. Wait a minute or two and retry, with a cap.
The error, verbatim
You will usually meet it in the STK callback:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 1025,
"ResultDesc": "Unable to Send STK Prompt"
}
}
}
Match on ResultCode, never on ResultDesc. The description text varies in casing and wording between environments and over time; the numeric code does not. And treat 9999 as an alias for 1025, because it represents the same underlying failure and should be handled identically.
Why the same code means two things
Daraja validates some fields strictly and returns a clear message, and it validates others by failing somewhere downstream and returning a generic one. TransactionDesc falls into the second group. When it is too long, the STK prompt is never dispatched, and the platform reports the outcome it observed: it was unable to send the prompt. Which is also, word for word, what it reports when its own SMS gateway is having a bad ten minutes.
The reason this bites production code rather than test code is how the field usually gets built:
// Looks harmless in dev with short test data.
const transactionDesc = `Payment for ${invoice.number} - ${customer.name} - ${items
.map((i) => i.name)
.join(', ')}`
Every one of those inputs is user-controlled and unbounded. A customer called "Ann" with one item produces 40 characters. A distributor with a long registered company name and eleven line items produces 300. Your staging data never had eleven line items, so the bug ships.
There is a second wrinkle worth knowing. Safaricom's field specification gives TransactionDesc a maximum of 13 characters, but the gateway plainly accepts more than that, which is why practically nobody obeys the documented limit and why the failure appears at 182 rather than at 14. Both numbers are useful: 13 is the contract you are supposed to honour, 182 is the cliff you fall off.
The fix
Step 1: Validate before you call Daraja
The correct place to catch this is in your own code, milliseconds before the request goes out, where you can produce an error a human can act on:
const TRANSACTION_DESC_DOCUMENTED_MAX = 13
const TRANSACTION_DESC_HARD_MAX = 182
const ACCOUNT_REFERENCE_MAX = 12
export class StkPayloadError extends Error {
constructor(public field: string, public detail: string) {
super(`${field}: ${detail}`)
}
}
export function assertStkPayload(p: {
accountReference: string
transactionDesc: string
}) {
if ([...p.accountReference].length > ACCOUNT_REFERENCE_MAX) {
throw new StkPayloadError(
'AccountReference',
`${[...p.accountReference].length} characters, maximum ${ACCOUNT_REFERENCE_MAX}`
)
}
const descLength = [...p.transactionDesc].length
if (descLength > TRANSACTION_DESC_HARD_MAX) {
throw new StkPayloadError(
'TransactionDesc',
`${descLength} characters, hard limit ${TRANSACTION_DESC_HARD_MAX}`
)
}
if (descLength > TRANSACTION_DESC_DOCUMENTED_MAX) {
console.warn('stk_transaction_desc_over_documented_limit', { descLength })
}
}
Two details. Count with [...string] rather than .length so an emoji in a customer's business name is counted as one character rather than two. And warn rather than throw between 13 and 182, because throwing at 13 would break every existing integration on the day you deploy it.
Step 2: Build the description so it cannot grow
Do not truncate the whole string at the end and hope. Decide what has to survive:
function buildTransactionDesc(invoiceNumber: string): string {
// Keep it short, stable and reconcilable. The customer sees
// AccountReference in the prompt, not this field.
return invoiceNumber.slice(0, TRANSACTION_DESC_DOCUMENTED_MAX)
}
The instinct to put a friendly sentence in TransactionDesc comes from thinking the customer reads it. They do not. The value shown in the STK PIN prompt alongside your business name is AccountReference, which has its own maximum of 12 alphanumeric characters. That is the field to spend your characters on, and it is the one your finance team will use to match the payment later, which makes it part of how the paybill statement reconciles.
Step 3: Handle 1025 with a classifier, not a retry loop
const TRANSIENT_STK_CODES = new Set([1025, 9999])
async function handleStkResult(tx: Transaction, resultCode: number) {
if (!TRANSIENT_STK_CODES.has(resultCode)) return handleOther(tx, resultCode)
// We recorded this at request time. If we did not, that is the first bug to fix.
if (tx.transactionDescLength > TRANSACTION_DESC_HARD_MAX) {
await markPermanentFailure(tx, 'payload_transaction_desc_too_long')
await alertEngineering(tx)
return
}
if (tx.attempts >= 3) {
await markPermanentFailure(tx, 'stk_send_failed_after_retries')
return
}
await scheduleRetry(tx, { delayMs: 90_000 })
}
Note tx.transactionDescLength. You cannot classify the error after the fact unless you recorded the length when you sent the request. One integer column pays for itself the first time this happens.
Step 4: Make the retry safe
A retried STK push is a new request with a new CheckoutRequestID. If the first prompt did in fact reach the phone and the customer paid, a blind retry can produce a second prompt and a second payment. Before retrying, query the original request rather than assuming it went nowhere:
POST https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query
Query once, with a delay, not in a loop. Hammering that endpoint is the fastest route to a 429 whose error body your parser cannot read. And the retry should be keyed on your own transaction record so a duplicate callback cannot double-post to your ledger, which is the same idempotency discipline as handling an M-Pesa callback that fires twice.
Verification
1. A unit test that would have caught the original bug.
it('rejects a TransactionDesc built from long customer data', () => {
const desc = buildDescFromInvoice(invoiceWithElevenLineItems)
expect([...desc].length).toBeLessThanOrEqual(182)
})
2. Log the length on every outbound request.
{
"event": "stk_push_request",
"checkout_request_id": "ws_CO_191220191020363925",
"account_reference_len": 9,
"transaction_desc_len": 11
}
Then, after a week, check the maximum observed value. If it is anywhere near 182 you have a latent outage waiting for a customer with a long name.
3. Reproduce it deliberately in sandbox. Send one STK push with a 200 character TransactionDesc and confirm you get 1025. Send the identical request with a short description and confirm it succeeds. Now you know, for your account and your API version, that the boundary is real rather than folklore.
What people get wrong
| The habit | What it costs |
|---|---|
| Treating every 1025 as a Safaricom outage | Deterministic payload failures retry forever and never succeed. The customer just sees a payment that does not work. |
| Writing separate handlers for 1025 and 9999 | Two code paths, one underlying error, and inevitably only one of them gets the fix. |
| Retrying immediately with no cap | You turn one failed payment into a burst of requests, which is exactly the traffic pattern that gets you rate limited. |
Putting the invoice narration in TransactionDesc | The customer never sees it. You have spent your character budget on a field nobody reads, in the one place with an unhelpful failure mode. |
Truncating with slice(0, 182) and calling it fixed | You have hidden the symptom. The description is now meaningless mid-word, and the real problem, an unbounded template, is still there. |
| Opening a Safaricom ticket first | Check the length before you escalate. It takes thirty seconds and it is the answer more often than people expect. |
When it is still broken
- Length is fine and it fails consistently for one number. That points at the subscriber rather than the payload: a phone that is off, out of coverage, or not M-Pesa registered. Those usually surface as other codes, but a device that never acknowledges can present oddly. Test the same amount to a different number.
- It fails for every request, immediately after a deploy. Check credentials and the timestamp used in the password. A malformed
Passwordor a clock that has drifted produces failures that look like platform problems. Run NTP on the server. - It fails only in production, never in sandbox. Compare the two payloads field by field. Production shortcodes and sandbox shortcodes have different lengths, and a hardcoded sandbox value that survived into production is a classic.
- It really is Safaricom. It happens. The tell is that failures start and stop across all your customers at once rather than following particular records. Back off, keep the transactions in a retriable state, and let them drain when service returns. The Daraja production guide covers the callback and state machine you need for that to be safe.
The general lesson is worth more than the specific limit: when a payment API gives you one code for both "your request was wrong" and "we are having a moment", the only way to tell them apart later is to record enough about the request to tell them apart. One integer column beats an afternoon of guessing.
Frequently asked questions
- What causes M-Pesa error 1025, 'Unable to Send STK Prompt'?
- Two different things. Either the TransactionDesc in your request was too long, in which case the prompt was never dispatched and every retry will fail identically, or M-Pesa had a temporary system issue, in which case retrying after a minute or two usually works. Check the length of the TransactionDesc you sent before assuming it is a platform problem.
- Is M-Pesa error 9999 different from 1025?
- No. 9999 represents the same underlying failure as 1025 and should be handled identically. Writing two separate code paths for them is a common mistake that leads to a fix being applied to only one of them. Treat both as the same condition in your result-code classifier.
- What is the maximum length of TransactionDesc in the Daraja STK Push API?
- Safaricom's field specification gives TransactionDesc a maximum of 13 characters, but the gateway accepts considerably more in practice and requests are reported to fail with error 1025 once the value exceeds 182 characters including spaces. Validate against 182 as a hard limit so you fail fast with a clear message, and aim for the documented 13 in new code. AccountReference, which is the value shown to the customer in the PIN prompt, has a maximum of 12 alphanumeric characters.
- Is it safe to retry an STK push after error 1025?
- Only if you first confirm the payload was valid and you cap the number of attempts. A retried STK push is a new request with a new CheckoutRequestID, so if the original prompt did reach the customer you can end up prompting and charging twice. Query the original CheckoutRequestID once, after a delay, before scheduling a retry, and key the retry on your own transaction record so duplicate callbacks cannot double-post to your ledger.