STK Push Explained: How Lipa Na M-Pesa Actually Works
STK Push (also called Lipa Na M-Pesa Online) lets your app trigger a payment prompt directly on a customer's phone. Your server sends a request to Safaricom, the customer sees a PIN prompt, enters their PIN, and Safaricom sends the result to your callback URL. The entire flow is asynchronous, meaning your initial API call only starts the process. The actual payment result arrives separately via callback.
What Is STK Push and Why Does It Matter?
STK stands for SIM Toolkit. STK Push (officially called "Lipa Na M-Pesa Online") is a mechanism that lets your application trigger a payment prompt directly on a customer's phone. Instead of the customer manually going to the M-Pesa menu, entering a paybill number, account reference, and amount, your app does all of that with a single API call. The customer only has to enter their M-Pesa PIN.
This matters because it dramatically reduces friction. Every step you remove from a payment process increases the chance the customer actually completes it. With a manual paybill payment, the customer has to remember (or look up) your paybill number, type it correctly, enter the right account reference, type the amount, then confirm. That is five separate opportunities for them to make a mistake or abandon the process. STK Push reduces all of that to one step: enter your PIN.
For developers building apps in Kenya, STK Push is usually the first M-Pesa integration you will implement, and often the only one you need for a basic e-commerce or service payment flow. It handles the most common scenario: "customer wants to pay for something through your app."
Every major Kenyan app that accepts M-Pesa payments (M-Shwari, Jumia, Bolt, most food delivery apps) uses STK Push behind the scenes. If your app needs to accept payments, this is where you start.
What the User Sees: The Payment Flow from the Customer Side
Before diving into the technical implementation, let us look at what the customer actually experiences. Understanding the user side helps you build better error messages and design better UX around the payment process.
Here is the sequence from the customer's perspective:
- The customer taps "Pay" in your app. Your frontend sends a request to your backend server. The customer sees a loading indicator or "Processing payment..." message.
- A payment prompt appears on their phone. This is a system-level dialog (not inside your app) that comes from the SIM Toolkit. It shows the business name, amount, and asks them to enter their M-Pesa PIN. On some phones, this prompt appears over whatever the customer is currently doing. On others, it shows as a notification they need to tap.
- The customer enters their M-Pesa PIN. This is the same 4-digit PIN they use for all M-Pesa transactions. After entering the PIN, they see a brief "Processing..." message.
- The customer receives an M-Pesa confirmation SMS. This is the standard M-Pesa confirmation message with the transaction code, amount, and new balance. Your app should also update to show a "Payment successful" state.
From the customer's point of view, the whole thing takes about 10 to 15 seconds if they enter their PIN quickly. The key UX insight here is timing: there is a gap between when your app says "processing" and when the customer actually sees the prompt on their phone. This gap can be 2 to 8 seconds depending on network conditions. Your app needs to clearly tell the user to check their phone for the M-Pesa prompt during this window. Otherwise they sit there wondering if anything happened.
If the customer does not enter their PIN within approximately 30 seconds, the prompt disappears and the transaction is cancelled. Your app needs to handle this gracefully, not just show an endless loading spinner. Tell the user the request timed out and offer to try again.
One thing that surprises some developers: the STK Push prompt is not a notification or a message. It is a SIM-level dialog generated by the SIM Toolkit on the phone. This means it behaves differently from app notifications. It can appear even when the phone is locked (depending on the phone model), and the user cannot dismiss it by accident the way they might swipe away a notification.
The Full Technical Flow: What Happens Behind the Scenes
Now for the developer side. Here is the complete sequence of what happens when you initiate an STK Push, from your server's first request to the final callback:
Step 1: Your server sends an STK Push request to Safaricom.
Your backend calls the Daraja API endpoint /mpesa/stkpush/v1/processrequest with the payment details: amount, customer phone number, callback URL, and authentication credentials. This is a standard POST request with a JSON body.
Step 2: Safaricom validates and responds immediately.
Within 1 to 3 seconds, Safaricom responds with a synchronous response. If everything is valid, you get a ResponseCode of "0" along with a MerchantRequestID and CheckoutRequestID. These IDs are your reference for tracking this specific transaction. Save them. If validation fails (bad credentials, invalid phone number, wrong format), you get an error response right here.
Step 3: Safaricom pushes the prompt to the customer's phone.
This happens asynchronously after the initial response. There is a network delay here that varies from 2 to 8 seconds. Your server has already received the "request accepted" response and is now waiting for the callback.
Step 4: The customer enters their PIN (or does not).
Three things can happen: the customer enters the correct PIN (success), the customer enters the wrong PIN (failure), or the customer does nothing and the prompt times out (cancellation). Each of these outcomes produces a different callback.
Step 5: Safaricom sends the result to your callback URL.
Safaricom makes a POST request to the CallBackURL you specified in Step 1. The callback body contains the transaction result, including whether it succeeded or failed, the M-Pesa receipt number (if successful), the amount, and the phone number. Your server processes this callback to update the order status in your database.
A visual way to think about this flow:
Your App -> Your Server -> Safaricom API (synchronous response)
|
v
Customer's Phone (STK prompt appears)
|
v
Customer enters PIN
|
v
Safaricom -> Your Callback URL (async result)
The crucial point: there are two separate communications. The synchronous request/response (Steps 1-2) and the asynchronous callback (Step 5). Your architecture needs to handle both. The initial response tells you "the process started." The callback tells you "here is what happened."
Handling the Callback Properly
The callback is where most implementations break down. Getting the STK Push request right is straightforward. Handling the callback correctly is where real-world complexity lives.
Here is what a successful callback looks like:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 1 },
{ "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
{ "Name": "TransactionDate", "Value": 20260719143022 },
{ "Name": "PhoneNumber", "Value": 254708374149 }
]
}
}
}
}
And here is a failed or cancelled callback:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 1032,
"ResultDesc": "Request cancelled by user."
}
}
}
Notice the difference: successful callbacks include CallbackMetadata with the receipt number and amount. Failed callbacks only have ResultCode and ResultDesc, with no metadata. Your callback handler needs to check for both cases.
Here is a basic Express.js callback handler:
app.post('/mpesa/callback', (req, res) => {
const callback = req.body.Body.stkCallback;
const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc } = callback;
if (ResultCode === 0) {
// Payment succeeded
const metadata = callback.CallbackMetadata.Item;
const amount = metadata.find(i => i.Name === 'Amount').Value;
const receipt = metadata.find(i => i.Name === 'MpesaReceiptNumber').Value;
const phone = metadata.find(i => i.Name === 'PhoneNumber').Value;
// Update your database: mark the order as paid
console.log(`Payment of ${amount} received. Receipt: ${receipt}`);
} else {
// Payment failed or was cancelled
console.log(`Payment failed: ${ResultDesc} (code ${ResultCode})`);
// Update your database: mark the order as failed
}
// IMPORTANT: Always respond with 200 OK
// If you do not, Safaricom will retry the callback
res.status(200).json({ message: 'Callback received' });
});
The line at the bottom is critical. You must respond to the callback with a 200 status code. If your server returns an error (or does not respond at all), Safaricom assumes the callback was not delivered and may retry it. This can lead to duplicate processing if your handler is not idempotent. Always respond with 200, even if your internal processing fails. Log the error internally and deal with it separately.
Common Gotchas That Will Bite You
These are the issues that consistently trip up developers, even experienced ones. Learn from others' mistakes.
1. Callback URL must be HTTPS and publicly accessible.
Safaricom will only send callbacks to HTTPS URLs. HTTP will not work. And "publicly accessible" means accessible from the internet, not just your local network. During development, use ngrok (ngrok http 3000) to create a temporary public URL that tunnels to your localhost. In production, your server needs a real domain with an SSL certificate.
2. The 30-second timeout is real.
If the customer does not enter their PIN within roughly 30 seconds, the transaction is cancelled. You cannot extend this. Your UX needs to account for it. Show a countdown or a clear message telling the user to check their phone immediately. Do not let them navigate away from the payment page during this window.
3. Duplicate requests from impatient users.
Users will tap "Pay" multiple times if they do not see an immediate response. Each tap can trigger a new STK Push, resulting in multiple prompts on the customer's phone and potential double charges. Prevent this by disabling the pay button after the first tap and tracking pending transactions by phone number. If a transaction is already pending for that phone number, do not initiate another one.
4. Sandbox does not send real push notifications.
When testing in the sandbox, no actual prompt appears on any phone. The sandbox simulates the flow and returns a callback as if the user entered their PIN. This means you cannot test the actual user experience in the sandbox. The first time you see the real STK prompt behavior will be when you test with live credentials. Plan for this.
5. Callbacks can arrive late or not at all.
Network issues, Safaricom server load, or your server being temporarily unreachable can all cause callbacks to be delayed or lost entirely. This is why you should never rely solely on callbacks. Implement a fallback: use the Transaction Status API to query the result of a transaction if you have not received a callback within a reasonable window (say, 2 minutes). Poll at sensible intervals, not every second.
6. Different behavior between test and production.
The sandbox always succeeds (by default). Production has real failure modes: insufficient balance, wrong PIN, account restrictions, network timeouts, and more. Your code needs to handle all of these gracefully. Do not assume your sandbox-tested code is production-ready without adding comprehensive error handling.
7. Phone number format inconsistency.
Kenyan phone numbers can be written as 0712345678, +254712345678, or 254712345678. The Daraja API expects the format 254XXXXXXXXX (no plus sign, starts with 254). Validate and normalize phone numbers before sending them to the API. A simple formatting function saves you hours of debugging "invalid phone number" errors.
Using Transaction Status as a Safety Net
Callbacks are the primary way you learn about payment results, but they are not guaranteed. Your callback server might be down. Safaricom might have a delivery issue. The internet might hiccup at exactly the wrong moment. This is why every production M-Pesa integration needs a fallback mechanism: the Transaction Status Query API.
The Transaction Status API lets you ask Safaricom, "Hey, what happened with this specific transaction?" You can call it with the CheckoutRequestID you saved from the initial STK Push response.
async function queryTransactionStatus(accessToken, checkoutRequestID) {
const shortcode = '174379';
const passkey = 'bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919';
const timestamp = new Date()
.toISOString()
.replace(/[-T:.Z]/g, '')
.slice(0, 14);
const password = Buffer.from(`${shortcode}${passkey}${timestamp}`).toString('base64');
const response = await fetch(
'https://sandbox.safaricom.co.ke/mpesa/stkpushquery/v1/query',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
BusinessShortCode: shortcode,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestID,
}),
}
);
const data = await response.json();
console.log('Transaction Status:', data);
return data;
}
A common pattern is to set up a check after a timeout. If you have not received a callback within 2 minutes of initiating the STK Push, query the transaction status. If the query shows the transaction succeeded but your callback never arrived, update your database accordingly. If it shows the transaction is still pending, wait a bit longer. If it shows failure or cancellation, mark it as such and let the user try again.
Do not poll aggressively. Querying every second is unnecessary and will get your API access throttled by Safaricom. A reasonable approach: query once at 30 seconds, once at 60 seconds, and once at 120 seconds. If you still do not have a definitive answer after 2 minutes, the transaction almost certainly timed out or was cancelled.
In a production system, you would typically combine callbacks with a scheduled job (cron or similar) that sweeps for any transactions in a "pending" state older than a few minutes and queries their status. This catches any callbacks that were lost and ensures your database eventually reflects the true state of every transaction.
Where to Go from Here
If you have followed along this far, you understand the complete STK Push lifecycle: initiation, user experience, callback handling, and fallback queries. That is the foundation for every M-Pesa payment integration you will ever build.
Here is what to tackle next:
- Set up your sandbox properly. If you have not done this yet, our sandbox setup guide walks through every credential and configuration step.
- Build a complete payment flow. Connect the STK Push to a real frontend with a payment button, loading state, success screen, and error handling. This is where you learn the UX side of payments.
- Learn C2B and B2C. STK Push covers customer-initiated payments. C2B handles paybill payments, and B2C lets you send money to customers (refunds, payouts). Together, these three cover the vast majority of M-Pesa use cases.
- Implement proper logging and reconciliation. In production, you need to track every transaction, match callbacks to requests, and reconcile your records against M-Pesa statements. This is the boring-but-critical part of payment integration.
If you want a structured, project-based path through all of this, our M-Pesa Integration for Developers course (KES 9,999) takes you from sandbox setup to a production-ready payment system. You build a working integration as the course project, not just watch someone else do it.
The Daraja API has its quirks, but once you understand the async flow and respect the callback pattern, it is a reliable and well-documented API. The hardest part is the first integration. After that, it becomes second nature.
Key Takeaways
- ✓STK Push is asynchronous. Your initial API call only tells Safaricom to send a payment prompt. The actual result (paid or not) comes through a separate callback to your server.
- ✓The user has about 30 seconds to enter their PIN after the prompt appears. If they do not respond, the transaction times out and your callback receives a cancellation.
- ✓Your callback URL must be publicly accessible on the internet. Localhost does not work. Use ngrok or a similar tunneling tool during development.
- ✓Always implement a transaction status query as a fallback. Callbacks can fail silently if your server is down or Safaricom has a delivery issue.
- ✓Sandbox behavior differs from production in meaningful ways. The sandbox does not actually push to a phone, response times are different, and some error codes only appear in production.
Frequently Asked Questions
- What does STK Push stand for?
- STK stands for SIM Toolkit. It refers to the technology built into SIM cards that allows operators like Safaricom to push interactive menus and prompts to a phone. "STK Push" specifically means the server initiates a payment prompt that appears on the customer's phone, as opposed to the customer manually navigating to the M-Pesa menu.
- How long does the customer have to enter their M-Pesa PIN?
- Approximately 30 seconds. If the customer does not respond within that window, the transaction is automatically cancelled by Safaricom and your callback receives a timeout/cancellation result. You cannot extend this timeout. Design your UX to clearly tell the user to check their phone immediately.
- Can I test STK Push without a real M-Pesa account?
- Yes. The Safaricom sandbox simulates the entire STK Push flow without involving any real phones or M-Pesa accounts. You send requests to the sandbox API, and it returns simulated callbacks. The sandbox uses test phone numbers and a test shortcode. You do not need a paybill or till number for sandbox testing.
- What happens if the user enters the wrong PIN?
- If the user enters an incorrect PIN, M-Pesa rejects the transaction. Your callback receives a ResultCode indicating the failure, typically with a description like "The initiator information is invalid." The user can try the payment again, but repeated wrong PIN attempts may temporarily lock their M-Pesa account (this is standard M-Pesa security, not something you control).
- Why am I not receiving callbacks in my local development environment?
- Safaricom sends callbacks to the URL you specified, and that URL must be accessible from the public internet. Your localhost (127.0.0.1 or localhost:3000) is not reachable from Safaricom's servers. Use a tunneling tool like ngrok to create a public URL that forwards to your local server. Run "ngrok http 3000" and use the generated HTTPS URL as your callback URL.
- Can the same phone number have multiple pending STK Push requests?
- No. If there is already a pending STK Push prompt on a phone number and you send another request for the same number, the second request will fail. Safaricom only allows one active STK Push session per phone number at a time. Your app should track pending transactions and prevent duplicate requests for the same phone number.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon