Founder & Lead Engineer, RAITHub
Before an M-Pesa STK Push integration goes live in Kenya, test 6 failure cases: the customer cancels, the prompt times out, the wallet has insufficient funds, the callback arrives twice, the amount is wrong, and the callback never arrives. Make the handler idempotent on CheckoutRequestID, treat the callback as untrusted, confirm through the STK Push query, and reconcile anything still pending.
This is engineering guidance for developers and CTOs adding M-Pesa Express, Safaricom's name for STK Push, to a web app in Kenya. RAITHub has not shipped an M-Pesa or Daraja integration. We have shipped the same class of problem: bKash, Nagad and SSLCommerz, Bangladesh's mobile-money-style wallets and gateway, in TheSkinProof, the founder's own marketplace venture, and Stripe rent collection in PropDesk. Safaricom's Daraja portal renders its API reference inside a signed-in web app, so the request fields below are taken from Safaricom's own open-source SDK on GitHub, checked on 30 September 2026. Check them, and the callback sample, against the portal for your account before you build.
How does an M-Pesa STK Push payment actually work?
Your server asks Safaricom to send a payment prompt to the customer's phone, the customer enters their M-Pesa PIN, and Safaricom later posts the result to a URL you supplied. Your server never sees the PIN, and the answer arrives separately from the request.
| Step | Who acts | What happens |
|---|---|---|
| 1. Request | Your server | POST /mpesa/stkpush/v1/processrequest with the shortcode, a password, a timestamp, TransactionType, Amount, PartyA, PartyB, PhoneNumber, CallBackURL, AccountReference and TransactionDesc |
| 2. Acknowledgement | Safaricom | A synchronous response that includes the CheckoutRequestID identifying this prompt. It means "queued", not "paid" |
| 3. Prompt | Customer | A prompt appears on the phone; they enter the PIN, cancel, or ignore it |
| 4. Callback | Safaricom | A POST to your CallBackURL with the result for that CheckoutRequestID |
| 5. Confirmation | Your server | POST /mpesa/stkpushquery/v1/query with the shortcode, password, timestamp and CheckoutRequestID, to get Safaricom's own answer |
Safaricom's Node SDK builds the password as the Base64 of the shortcode, the passkey and the timestamp joined together, with the timestamp as 14 digits (YYYYMMDDHHmmss), and uses CustomerPayBillOnline as the default transaction type (Safaricom mpesa-node-library). Its PHP SDK states the model plainly: "M-Pesa APIs are asynchronous" (Safaricom mpesa-php-sdk). Everything below follows from that sentence.
Why can't you trust the STK Push callback on its own?
Because it is an unauthenticated POST to a public URL, and because it can arrive twice, late or never. In the sources we could check, the callback carries no signature you can verify. Anyone who learns the URL can post a body that says the payment succeeded.
So the callback is a hint that something changed, not proof of payment. Three defences, in order of value:
- Confirm with the STK Push query before marking anything paid. A forged callback then triggers a read, not a delivery.
- Put an unguessable secret in the callback path, such as
/api/mpesa/stk/9f3c..., and rotate it if it leaks. It filters noise; it is not a substitute for step 1. - Never take the amount from the callback body. The amount is what your server asked for in step 1 and stored against the
CheckoutRequestID.
This is the rule every payment provider's documentation arrives at in some form. Flutterwave's, for example, says: "Before giving value to a customer based on a webhook notification, always re-query our API to verify the transaction details" (Flutterwave: webhooks). The same pattern for Stripe is in webhook returned 200 but the subscription did not update.
Which STK Push failure cases should you test before production?
These six, plus a forged callback. Each needs a decided outcome for the invoice, a message for the customer, and an automated test that replays it.
| Case | What you receive | What your system should do | How to test it |
|---|---|---|---|
| User cancels the prompt | A callback with a non-zero ResultCode | Confirm via query, mark the attempt failed, keep the invoice open, allow a new prompt | Sandbox: cancel on the test phone. CI: replay a recorded cancel callback with a fake query that returns failed |
| Timeout: the PIN is never entered | A non-zero ResultCode, or no callback at all | Same as cancel; tell the customer the prompt expired and offer to resend | CI: replay a timeout callback; separately, send none and run the reconciliation job |
| Insufficient funds | A non-zero ResultCode and a ResultDesc explaining why | Mark failed, show the reason, do not retry automatically | CI: replay a recorded insufficient-funds callback |
| Duplicate callback | The same CheckoutRequestID twice, possibly at the same moment | The second delivery changes nothing; one payment, one receipt SMS | CI: post the same body twice concurrently; assert one payment row and one message |
| Wrong amount | A callback whose Amount differs from what you requested | Ignore the body's amount; hold the attempt for review and alert | CI: replay a success callback with a tampered amount |
| Callback never arrives | Nothing | After a few minutes the reconciliation job queries and settles it; after an hour, send it to a person | CI: create a pending attempt, skip the callback, run the job with a fake query |
| Forged success callback | A success body for a prompt the customer never approved | The query says it failed or is pending; nothing is marked paid | CI: post a success body; fake query returns failed; assert the invoice is still open |
Result codes: 0 is success. For the rest, build your mapping from the codes your sandbox and production traffic actually return and from the list on the Daraja portal. Developers commonly report 1032 for a request cancelled by the user and 1037 for a phone that could not be reached, but we could not confirm those in a public Safaricom source, so do not hard-code behaviour to them. Store ResultCode and ResultDesc as received, show the description to support staff, and treat every non-zero code as "not paid".
What does an idempotent STK Push callback handler look like?
It records the delivery under a unique key, acknowledges quickly, and settles the payment from Safaricom's query rather than from the body. Idempotent means that running it twice with the same input has the same effect as running it once.
Start with the tables. One row per prompt you send, one row per callback you receive, and a partial unique index so an invoice can never have two open prompts at once:
CREATE TABLE stk_payments (
checkout_request_id text PRIMARY KEY, -- from Safaricom's response in step 2
invoice_id bigint NOT NULL REFERENCES invoices(id),
amount_kes integer NOT NULL CHECK (amount_kes > 0), -- what WE requested
status text NOT NULL DEFAULT 'pending', -- pending | paid | failed | review
result_code text,
result_desc text,
mpesa_receipt text,
created_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz
);
-- A second prompt for the same invoice cannot start while one is unresolved.
CREATE UNIQUE INDEX one_pending_stk_per_invoice
ON stk_payments (invoice_id) WHERE status = 'pending';
CREATE TABLE stk_callbacks (
checkout_request_id text PRIMARY KEY, -- the idempotency key
payload text NOT NULL, -- raw body, kept for disputes
received_at timestamptz NOT NULL DEFAULT now()
);
Then the route handler. This is a Next.js App Router route; db is a node-postgres pool and settle is shown next.
// app/api/mpesa/stk/[secret]/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { settle } from '@/lib/mpesa-settle'
interface StkCallbackBody {
Body?: {
stkCallback?: {
MerchantRequestID: string
CheckoutRequestID: string
ResultCode: number
ResultDesc: string
CallbackMetadata?: { Item: { Name: string; Value?: string | number }[] }
}
}
}
export async function POST(req: Request, ctx: { params: Promise<{ secret: string }> }) {
const { secret } = await ctx.params
if (secret !== process.env.MPESA_CALLBACK_SECRET) {
return new NextResponse(null, { status: 404 })
}
const raw = await req.text()
let checkoutId: string | undefined
try {
checkoutId = (JSON.parse(raw) as StkCallbackBody).Body?.stkCallback?.CheckoutRequestID
} catch {
checkoutId = undefined
}
if (!checkoutId) return NextResponse.json({ ok: true }) // log and drop malformed bodies
// 1. Record the delivery. A duplicate hits the primary key and inserts nothing.
const inserted = await db.query(
'INSERT INTO stk_callbacks (checkout_request_id, payload) VALUES ($1, $2) ' +
'ON CONFLICT (checkout_request_id) DO NOTHING',
[checkoutId, raw],
)
if (inserted.rowCount === 0) return NextResponse.json({ ok: true }) // already seen
// 2. The body is a hint. Settle from Safaricom's own answer.
await settle(checkoutId)
return NextResponse.json({ ok: true })
}
And the settlement function, shared by the handler and the reconciliation job. stkQuery wraps the query endpoint and turns its response into one of three states; build that mapping from the responses you observe in the sandbox, including the one returned while a payment is still being processed, which must count as pending, not failed.
// lib/mpesa-settle.ts
import { db } from '@/lib/db'
import { stkQuery } from '@/lib/mpesa' // POST /mpesa/stkpushquery/v1/query
export type QueryResult =
| { state: 'pending' }
| { state: 'success' | 'failed'; resultCode: string; resultDesc: string }
export async function settle(checkoutId: string): Promise<void> {
const q: QueryResult = await stkQuery(checkoutId)
if (q.state === 'pending') return // the reconciliation job will ask again
// One conditional update: only a pending attempt can move, and only once.
await db.query(
'UPDATE stk_payments SET status = $2, result_code = $3, result_desc = $4, settled_at = now() ' +
"WHERE checkout_request_id = $1 AND status = 'pending'",
[checkoutId, q.state === 'success' ? 'paid' : 'failed', q.resultCode, q.resultDesc],
)
// Mark the invoice paid, send the receipt SMS and so on AFTER this commits,
// and only if the update changed a row.
}
Two design choices matter more than the code. The callback insert and the settlement are separate, so a crash between them leaves a pending attempt that the reconciliation job picks up; nothing is lost. And the WHERE status = 'pending' clause is what makes concurrent deliveries safe: two requests can both reach the update, but only one changes a row. The same conditional update keeps bKash and SSLCommerz confirmations safe in the bKash, Nagad and SSLCommerz guide.
For the wrong-amount case, compare the callback's Amount item with amount_kes before settling. If they differ, set the attempt to review and alert; do not settle from either number until a person has looked. For high-value payments you can also confirm the M-Pesa receipt through Daraja's Transaction Status API, which Safaricom's SDK calls with the command TransactionStatusQuery and which answers asynchronously to a result URL you supply.
How do you handle an STK Push timeout or a callback that never arrives?
With a scheduled job that asks Safaricom about every prompt still pending after a few minutes, and hands anything unresolved to a person after an hour. A dropped callback then costs a short delay, not a lost payment or a customer charged twice.
// jobs/reconcile-stk.ts: run every 2 minutes from your scheduler.
import { db } from '@/lib/db'
import { settle } from '@/lib/mpesa-settle'
export async function reconcileStk(): Promise<void> {
const { rows } = await db.query(
"SELECT checkout_request_id FROM stk_payments WHERE status = 'pending' " +
"AND created_at < now() - interval '2 minutes' ORDER BY created_at LIMIT 100",
)
for (const r of rows) {
try {
await settle(r.checkout_request_id)
} catch (err) {
console.error('stk reconcile failed', r.checkout_request_id, err) // retry next run
}
}
// Still unknown after an hour: stop asking and put it in front of a person.
await db.query(
"UPDATE stk_payments SET status = 'review' " +
"WHERE status = 'pending' AND created_at < now() - interval '60 minutes'",
)
}
The 2-minute and 60-minute values are starting points; tune them from your own traffic. Three rules keep the job safe:
- Never send a new prompt automatically. If the customer taps "Pay" again, resolve the previous attempt through the query first. The partial unique index enforces this: a second pending row for the invoice cannot be inserted.
- Rate-limit your own queries. The job should batch and back off, not call the query endpoint for every row every minute.
- Alert on the review queue, not on every failure. Cancels and insufficient funds are normal; a growing review queue is the signal that something upstream changed.
How do you test STK Push in the Daraja sandbox and in CI?
Use the sandbox to learn the real shapes, and CI to prove the behaviour. The sandbox shows you what Safaricom actually sends; recorded copies of those bodies, replayed in CI against a fake query client, test the cases a sandbox cannot produce on demand.
- Record in the sandbox. Trigger a success, a cancel and an ignored prompt, and save each callback body and query response as a fixture file.
- Put the Daraja client behind an interface.
stkQueryis injected, so tests swap in a fake that returns pending, success or failed on command. - Replay every row of the failure table as an integration test against a real Postgres database, because the idempotency lives in its constraints.
- Test concurrency directly. Fire the same callback twice with
Promise.alland assert one paid row and one message sent. - Run the job in tests. Create a pending attempt, advance the clock, run
reconcileStk, and assert the outcome for each fake query answer.
The broader method, including webhook replay and sandbox contracts, is in testing payments and webhooks end to end. If callbacks are simply not reaching you, the checklist in why a webhook is not firing applies to Daraja too: public HTTPS URL, no auth wall in front of it, and the route actually deployed.
What about M-Pesa payments made from the Paybill menu instead of STK Push?
They arrive through a different API with the same rules. Customers who pay from the M-Pesa menu with your Paybill number are reported through C2B, where you register a confirmation URL and a validation URL with the C2B Register URL API (Safaricom mpesa-node-library). Key those confirmations on the M-Pesa transaction ID, insert with a unique constraint, and reconcile them against your Paybill statement daily. Most products need both paths: STK Push from your app, C2B for people who pay from the menu.
Why RAITHub for this?
- Mobile-money-style rails have shipped. TheSkinProof, the founder's own venture, runs bKash, Nagad, SSLCommerz and cash on delivery through one checkout, with 750+ automated tests across 217 API endpoints. Wallet redirects, server-to-server confirmation and "confirmed twice" handling are the same problems as STK Push.
- Money paths under test. PropDesk runs Stripe rent collection under 1,024 automated tests, and PadhAI, built by RAITHub, supports 9 payment gateways.
- Honest scope. M-Pesa Express, C2B and Daraja are new work for RAITHub, quoted as such and built against the sandbox with every row of the failure table as a test.
- Hours that work with Nairobi. Nairobi is on UTC+3 and Dhaka on UTC+6: about 6 shared working hours. The working week is agreed with you.
- Clear terms. A free 15-minute technical audit, then a fixed written quote; you own the code. See the API and backend development service for the integration, and the QA and test automation service for the test suite around it.
When you don't need us
- You collect payments through an aggregator's hosted checkout that handles M-Pesa for you. Integrate the aggregator's webhook and verify endpoint instead; you may never touch Daraja.
- You need a team with M-Pesa integrations already in production. RAITHub does not have one yet.
- Your platform has a maintained M-Pesa plugin. Install it, then run the failure table against it in the sandbox.
- You need licensing advice for holding or moving customer funds in Kenya. Ask a qualified adviser before you build.
- You need a native mobile app. RAITHub builds web apps and PWAs, not native iOS or Android apps.
Last reviewed: 30 September 2026. Safaricom SDK sources checked on 30 September 2026; confirm field names and result codes against the Daraja portal for your account.
More on payments architecture is in the payments engineering guide. When you are ready, book the free 15-minute technical audit and bring your Daraja sandbox credentials status and a description of what a payment unlocks in your product.
Frequently asked questions
How do I integrate the M-Pesa STK Push API?
Call /mpesa/stkpush/v1/processrequest from your server with your shortcode, a Base64 password built from shortcode, passkey and timestamp, the amount, the phone number, a callback URL and an account reference. Store the returned CheckoutRequestID, then settle each payment from the STK Push query, not from the callback body.
Is the M-Pesa STK Push callback secure?
Treat it as untrusted. In the sources we could check it carries no signature to verify, so anyone who learns the URL can post to it. Use a secret path, record each delivery once, and confirm the result with the STK Push query before marking anything paid.
What should happen if the M-Pesa callback never arrives?
A scheduled job should query every attempt still pending after a few minutes and settle it from Safaricom's answer. Anything still unresolved after about an hour goes to a person. Never send a new prompt for the same invoice until the old one is resolved.
How do I stop duplicate M-Pesa callbacks creating two payments?
Insert each callback into a table whose primary key is the CheckoutRequestID, ignoring conflicts, and settle with a conditional update that only changes a row still marked pending. A second delivery then inserts nothing and updates nothing.
What does ResultCode 0 mean in an STK Push callback?
0 means the request succeeded. Treat every non-zero code as not paid, store the code and ResultDesc as received, and build your mapping of specific codes from your sandbox and production traffic and the Daraja portal's list.
Has RAITHub built an M-Pesa integration?
No. RAITHub has shipped bKash, Nagad and SSLCommerz in TheSkinProof, the founder's own venture, Stripe rent collection in PropDesk and 9 payment gateways in PadhAI. M-Pesa and Daraja work would be quoted as new work using the patterns in this guide.
Related posts
Technical SEO Checklist for 2026: The Foundation That Lets You Rank
7 min readLocal and Geo SEO for Service Businesses: Rank Where Your Customers Are
7 min readSaaS Entitlements: Enforcing Plans, Limits and Add-ons in Code
14 min readReady to discuss your project?
Book a free 15-minute technical audit with our engineering team.