Introduction: Why M-Pesa Integration Is Non-Negotiable for Kenyan Websites
Let me be honest with you — if you're building a website for a Kenyan business and it doesn't accept M-Pesa, you're leaving money on the table. Literally.
M-Pesa isn't just a payment method here in Kenya. It's the financial infrastructure. With over 30 million monthly active users and a staggering KES 40.59 trillion ($314 billion) processed in the financial year ending March 2024 according to Safaricom's Annual Report, M-Pesa is the default way Kenyans pay for things online. Not Visa. Not PayPal. M-Pesa.
Kenya's mobile money penetration stands at approximately 76% of the adult population — the highest in Africa, according to the Central Bank of Kenya. And Lipa Na M-Pesa merchant payments grew 22.3% year-on-year in FY2024. That's not a trend. That's a tidal wave.
So how do you actually connect your website to M-Pesa? Through Safaricom's Daraja API. Launched in 2017 to replace the older (and frankly painful) G2 API, Daraja is a self-service developer portal that gives you RESTful APIs for everything from triggering payment prompts on a customer's phone to sending money back to users.
I once spent three sleepless nights debugging an M-Pesa callback integration for a client's e-commerce site. The issue turned out to be a single missing slash in the callback URL. That experience taught me to always triple-check payment gateway configurations — and it's exactly the kind of pain I want to help you avoid with this guide.
Whether you're building an e-commerce store, a SaaS product, a school fees portal, or a donation platform, this M-Pesa Daraja API integration guide will walk you through everything: sandbox setup, STK Push implementation, C2B and B2C flows, callback handling, security, going live, and the common pitfalls that trip up even experienced Kenyan developers. Let's get into it.
Prerequisites for This Guide
Before we start, make sure you have:
- Basic knowledge of RESTful APIs and HTTP requests
- A server-side language ready to go — we'll use Node.js/Express for code examples, but the concepts apply equally to PHP, Python, Laravel, or any backend framework
- A free Safaricom Daraja developer account at developer.safaricom.co.ke
- A registered Safaricom paybill or till number for production deployment (sandbox works without one)
Understanding the Daraja API: Architecture and Available Endpoints
Before writing a single line of code, you need to understand what the Daraja API actually offers. Think of it as a toolbox — each API endpoint is a different tool, and picking the wrong one will cause you headaches down the road.
The Daraja API lives at developer.safaricom.co.ke and follows a standard RESTful architecture. You send HTTP requests (mostly POST, with one GET for auth tokens), include your access token in the header, and receive JSON responses. Simple enough in theory.
Here's where it gets interesting. Safaricom provides seven distinct APIs, each designed for a specific money flow:
| API | Use Case | Money Flow |
|---|---|---|
| STK Push (Lipa Na M-Pesa Online) | Online checkout — triggers payment prompt on customer's phone | Customer → Business |
| C2B (Customer to Business) | Paybill/till number payments initiated by customer | Customer → Business |
| B2C (Business to Customer) | Disbursements, refunds, salary payments | Business → Customer |
| B2B (Business to Business) | Payments between businesses | Business → Business |
| Transaction Status | Query the status of any transaction | N/A (query only) |
| Account Balance | Check your M-Pesa account balance | N/A (query only) |
| Reversal | Reverse an erroneous transaction | Reversal |
From my experience, most Kenyan websites will primarily use STK Push — it's the Lipa Na M-Pesa Online API that powers the vast majority of online checkouts. If you're building a marketplace or platform that pays out to users (think delivery riders, content creators, or sellers), you'll also need B2C. And if you want to support customers who prefer the traditional M-Pesa menu flow — entering your paybill number manually on their phone — that's where C2B comes in.
The Transaction Status and Account Balance APIs are your safety nets. Trust me on this one — you'll need them more than you think. We'll cover why in the reconciliation section.
Setting Up Your Daraja Developer Account and Sandbox Environment
Alright, let's get practical. Head over to developer.safaricom.co.ke and create an account. It's free and takes about two minutes.
Once you're in, here's the step-by-step:
- Create a new app — Click "My Apps" then "Add a New App." Give it a name (something like "MyStore-Sandbox") and tick the APIs you need. For most projects, select Lipa Na M-Pesa Online, C2B, and B2C.
- Grab your credentials — After creating the app, you'll get a Consumer Key and Consumer Secret. These are your API credentials. Treat them like passwords.
- Note the sandbox test credentials — Safaricom provides pre-configured sandbox values: shortcode
174379for STK Push, a test passkey, and test phone numbers that return predetermined responses.
Here's something I wish someone had told me early on: the sandbox doesn't always perfectly mirror production. I've seen callbacks behave slightly differently, timing vary, and certain edge cases that only show up in production. So test thoroughly in sandbox, but don't assume everything will work identically when you go live.
Project Setup Best Practices
Before you write any code, set up your project structure properly:
- Create a
.envfile for your credentials from day one — never hardcode them - Add
.envto your.gitignoreimmediately - Set up separate environment variables for sandbox and production credentials
- Install Postman — it's invaluable for testing API calls before writing code
I always recommend developers spend at least an hour playing with the API in Postman before touching their codebase. Send a few auth requests, trigger a test STK Push, see what the responses look like. This saves you a ton of debugging time later.
OAuth 2.0 Authentication: Generating and Managing Access Tokens
Every single Daraja API call requires an access token. No token, no API access. It's the first thing you need to get right.
The authentication flow is straightforward:
- Take your Consumer Key and Consumer Secret
- Concatenate them with a colon:
consumer_key:consumer_secret - Base64-encode that string
- Send a GET request to the auth endpoint with that encoded string in the Authorization header
- Receive an access token that's valid for 3600 seconds (1 hour)
Sounds simple, right? The thing is, the devil is in the details. That 1-hour expiry catches so many developers off guard. Here's what happens: you generate a token, everything works great, you go to bed, wake up, and suddenly all your API calls are failing with "Invalid Access Token." Or worse — a customer is mid-checkout and the token expires between the time they click "Pay" and the time your server sends the STK Push request.
You must implement token caching and refresh logic. Generate a token, store it in memory or a cache (Redis is ideal), and check its expiry before every API call. If it's expired or about to expire, generate a new one.
const axios = require('axios');
let cachedToken = null;
let tokenExpiry = null;
async function getAccessToken() {
// Check if we have a valid cached token
// Refresh 60 seconds early to avoid edge cases
if (cachedToken && tokenExpiry && Date.now() < tokenExpiry - 60000) {
return cachedToken;
}
const consumerKey = process.env.MPESA_CONSUMER_KEY;
const consumerSecret = process.env.MPESA_CONSUMER_SECRET;
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64');
const baseUrl = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke';
try {
const response = await axios.get(
`${baseUrl}/oauth/v1/generate?grant_type=client_credentials`,
{
headers: {
Authorization: `Basic ${auth}`,
},
}
);
cachedToken = response.data.access_token;
// Token expires in 3600 seconds (1 hour)
tokenExpiry = Date.now() + (response.data.expires_in * 1000);
console.log('New M-Pesa access token generated successfully');
return cachedToken;
} catch (error) {
console.error('Failed to generate M-Pesa access token:', error.message);
throw new Error('M-Pesa authentication failed');
}
}
module.exports = { getAccessToken };
Notice that I refresh the token 60 seconds before it actually expires. This buffer prevents the scenario where your token is technically valid when you check it but expires by the time Safaricom processes your request. I've seen this countless times in production — a few seconds of network latency can turn a valid token into an expired one.
For production systems handling high traffic, consider using Redis or Memcached instead of in-memory storage. If you're running multiple server instances behind a load balancer, an in-memory token will only exist on one instance. Redis gives you a shared cache across all instances.
Implementing STK Push (Lipa Na M-Pesa Online API)
This is the heart of M-Pesa integration for most Kenyan websites. STK Push — officially called the Lipa Na M-Pesa Online API — is what triggers that familiar green payment prompt on your customer's phone. They enter their PIN, and the money moves from their M-Pesa wallet to your business account. No need for the customer to open the M-Pesa app, navigate menus, or type in your paybill number. It just works.
And that simplicity is exactly why STK Push implementation in Kenya has become the gold standard for online checkout. It reduces payment errors dramatically because the customer doesn't have to manually enter account references or amounts.
Here's what happens under the hood when you trigger an STK Push:
- Your server sends a POST request to Safaricom with the customer's phone number and amount
- Safaricom immediately returns a synchronous response confirming the request was received
- The customer sees a payment prompt on their phone
- They enter their M-Pesa PIN (or cancel/let it timeout — the STK Push window is approximately 60 seconds)
- Safaricom sends a callback to your server with the result
Let me walk you through the request payload field by field:
- BusinessShortCode — Your paybill or till number (174379 in sandbox)
- Password — Base64 encoding of: shortcode + passkey + timestamp
- Timestamp — Current time in the format YYYYMMDDHHmmss
- TransactionType — Either "CustomerPayBillOnline" or "CustomerBuyGoodsOnline"
- Amount — The amount to charge (whole numbers only, no decimals)
- PartyA — The customer's phone number (format: 254XXXXXXXXX)
- PartyB — Your shortcode (same as BusinessShortCode for paybill)
- PhoneNumber — Same as PartyA
- CallBackURL — Your HTTPS endpoint where Safaricom sends the result
- AccountReference — A reference for the transaction (e.g., order number, invoice ID)
- TransactionDesc — A brief description
const express = require('express');
const axios = require('axios');
const { getAccessToken } = require('./auth');
const router = express.Router();
router.post('/api/mpesa/stkpush', async (req, res) => {
try {
const { phone, amount, orderId } = req.body;
// Generate timestamp in YYYYMMDDHHmmss format
const timestamp = new Date()
.toISOString()
.replace(/[-T:.Z]/g, '')
.slice(0, 14);
const shortcode = process.env.MPESA_SHORTCODE; // 174379 for sandbox
const passkey = process.env.MPESA_PASSKEY;
// Password = Base64(shortcode + passkey + timestamp)
const password = Buffer.from(
`${shortcode}${passkey}${timestamp}`
).toString('base64');
// Normalize phone number to 254XXXXXXXXX format
const normalizedPhone = normalizePhone(phone);
const token = await getAccessToken();
const baseUrl = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke';
const response = await axios.post(
`${baseUrl}/mpesa/stkpush/v1/processrequest`,
{
BusinessShortCode: shortcode,
Password: password,
Timestamp: timestamp,
TransactionType: 'CustomerPayBillOnline',
Amount: Math.round(amount), // Must be whole number
PartyA: normalizedPhone,
PartyB: shortcode,
PhoneNumber: normalizedPhone,
CallBackURL: `${process.env.BASE_URL}/api/mpesa/callback`,
AccountReference: orderId || 'Payment',
TransactionDesc: `Payment for order ${orderId}`,
},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
// IMPORTANT: This response only means Safaricom received the request
// It does NOT mean the customer has paid!
if (response.data.ResponseCode === '0') {
// Store the CheckoutRequestID — you'll need it to match the callback
await saveCheckoutRequest({
checkoutRequestId: response.data.CheckoutRequestID,
merchantRequestId: response.data.MerchantRequestID,
orderId: orderId,
amount: amount,
phone: normalizedPhone,
status: 'pending', // NOT 'paid'
});
return res.json({
success: true,
message: 'Payment prompt sent to your phone. Please enter your M-Pesa PIN.',
checkoutRequestId: response.data.CheckoutRequestID,
});
} else {
return res.status(400).json({
success: false,
message: 'Failed to initiate payment. Please try again.',
});
}
} catch (error) {
console.error('STK Push error:', error.response?.data || error.message);
return res.status(500).json({
success: false,
message: 'Payment service temporarily unavailable.',
});
}
});
// Helper: Normalize Kenyan phone numbers to 254XXXXXXXXX
function normalizePhone(phone) {
let cleaned = phone.toString().replace(/[\s+\-]/g, '');
if (cleaned.startsWith('0')) {
cleaned = '254' + cleaned.slice(1);
} else if (cleaned.startsWith('+')) {
cleaned = cleaned.slice(1);
}
return cleaned;
}
module.exports = router;
Critical: Don't Confuse the Response with Payment Confirmation
The response you receive from the STK Push endpoint only confirms that Safaricom accepted your request — it does NOT mean the customer has paid. You must wait for the callback to confirm actual payment. Never update order status, deliver goods, grant access, or trigger fulfillment based on the synchronous response alone.
I've seen this mistake in production more times than I can count. A developer marks an order as "paid" the moment they get a ResponseCode of 0, and suddenly customers are getting products without actually completing payment. At the end of the day, the callback is the only source of truth.
Handling M-Pesa Callbacks Like a Pro
If STK Push is the heart of M-Pesa integration, callbacks are the brain. And honestly? This is where most integrations break. I've debugged more callback issues than I care to remember — that three-sleepless-nights story I mentioned earlier? Callback URL problem.
Here's how M-Pesa callback URL handling works: after the customer enters their PIN (or cancels, or the request times out), Safaricom sends a POST request to the CallBackURL you specified in your STK Push request. This callback contains the actual result of the transaction.
The Callback Payload
A successful STK Push callback looks something like this:
- ResultCode — 0 means success, anything else is a failure
- ResultDesc — Human-readable description ("The service request is processed successfully")
- MpesaReceiptNumber — The unique M-Pesa transaction receipt (e.g., "QKJ3A7B2C1")
- Amount — The amount paid
- TransactionDate — When the transaction was completed
- PhoneNumber — The customer's phone number
Critical Rules for Callback Handling
These aren't suggestions — they're survival rules:
- Your callback URL must be publicly accessible over HTTPS — localhost won't work. Safaricom's servers need to reach your endpoint from the internet.
- Respond with 200 OK immediately — Before you process anything. If Safaricom doesn't get a 200 response quickly, it may retry the callback, leading to duplicate processing.
- Implement idempotency — Check if you've already processed a callback with this MpesaReceiptNumber before updating your database. Duplicate callbacks happen.
- Log everything — Log the raw callback payload before processing. When something goes wrong at 2 AM (and it will), these logs are your lifeline.
- Validate the data — Don't blindly trust callback data. Verify that the amount matches what you expected, the phone number matches, and the CheckoutRequestID corresponds to a real pending transaction in your system.
router.post('/api/mpesa/callback', async (req, res) => {
// STEP 1: Respond immediately with 200 OK
// This prevents Safaricom from retrying the callback
res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });
// STEP 2: Log the raw callback for debugging
console.log('M-Pesa Callback Received:', JSON.stringify(req.body, null, 2));
try {
const { Body } = req.body;
const { stkCallback } = Body;
const { ResultCode, ResultDesc, CheckoutRequestID, CallbackMetadata } = stkCallback;
// STEP 3: Find the pending transaction
const transaction = await findByCheckoutRequestId(CheckoutRequestID);
if (!transaction) {
console.error(`No pending transaction found for: ${CheckoutRequestID}`);
return;
}
// STEP 4: Handle failed transactions
if (ResultCode !== 0) {
await updateTransactionStatus(transaction.id, {
status: 'failed',
resultCode: ResultCode,
resultDesc: ResultDesc,
});
console.log(`Payment failed for ${CheckoutRequestID}: ${ResultDesc}`);
return;
}
// STEP 5: Extract metadata from successful callback
const metadata = {};
if (CallbackMetadata && CallbackMetadata.Item) {
CallbackMetadata.Item.forEach((item) => {
metadata[item.Name] = item.Value;
});
}
const mpesaReceipt = metadata.MpesaReceiptNumber;
const amountPaid = metadata.Amount;
const phoneNumber = metadata.PhoneNumber;
// STEP 6: Idempotency check — prevent duplicate processing
const existingPayment = await findByMpesaReceipt(mpesaReceipt);
if (existingPayment) {
console.log(`Duplicate callback ignored for receipt: ${mpesaReceipt}`);
return;
}
// STEP 7: Validate amount matches expected amount
if (Number(amountPaid) !== Number(transaction.amount)) {
console.error(
`Amount mismatch! Expected: ${transaction.amount}, Got: ${amountPaid}`
);
// Flag for manual review rather than auto-confirming
await updateTransactionStatus(transaction.id, {
status: 'amount_mismatch',
mpesaReceipt,
amountPaid,
});
return;
}
// STEP 8: Update transaction as successful
await updateTransactionStatus(transaction.id, {
status: 'completed',
mpesaReceipt,
amountPaid,
phoneNumber,
completedAt: new Date(),
});
// STEP 9: Trigger post-payment actions
// (send confirmation SMS, update order, grant access, etc.)
await onPaymentSuccess(transaction.orderId, mpesaReceipt);
console.log(`Payment confirmed: ${mpesaReceipt} for order ${transaction.orderId}`);
} catch (error) {
console.error('Error processing M-Pesa callback:', error.message);
// Don't throw — we already sent the 200 response
// Log to error tracking service (Sentry, etc.)
}
});
Local Development Tip: Use ngrok for Callbacks
During local development, use ngrok to expose your localhost callback endpoint to the internet. Run ngrok http 3000 and use the generated HTTPS URL as your CallBackURL. For example: https://a1b2c3d4.ngrok.io/api/mpesa/callback
Remember that free ngrok URLs change every time you restart — consider a paid plan or deploying to a staging server for consistent testing. I personally deploy to a cheap staging server on a reliable host for M-Pesa testing. The few hundred shillings per month is worth the stability.
C2B Integration: Paybill and Till Number Payments
Not every M-Pesa payment starts from your website. Sometimes customers prefer the old-school approach — opening their M-Pesa menu, selecting "Lipa Na M-Pesa," entering your paybill number, and paying. The C2B (Customer to Business) API lets your system know when these payments happen.
C2B integration is a two-step setup:
Step 1: Register Your URLs
You need to register two URLs with Safaricom:
- Validation URL — Receives transaction details before the payment is completed. Your server can accept or reject the transaction in real-time.
- Confirmation URL — Fires after a successful transaction. This is your notification that money has landed.
The validation URL is incredibly powerful and often underused. Imagine you're selling concert tickets with limited stock. A customer tries to pay via M-Pesa for a ticket that just sold out 30 seconds ago. With the validation URL, your server can check inventory and reject the transaction before it completes. The customer's money never leaves their wallet. No refunds needed. No angry emails.
const axios = require('axios');
const { getAccessToken } = require('./auth');
// Register C2B URLs with Safaricom
async function registerC2BUrls() {
const token = await getAccessToken();
const baseUrl = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke';
const response = await axios.post(
`${baseUrl}/mpesa/c2b/v1/registerurl`,
{
ShortCode: process.env.MPESA_SHORTCODE,
ResponseType: 'Completed', // or 'Cancelled' to reject by default
ConfirmationURL: `${process.env.BASE_URL}/api/mpesa/c2b/confirmation`,
ValidationURL: `${process.env.BASE_URL}/api/mpesa/c2b/validation`,
},
{
headers: { Authorization: `Bearer ${token}` },
}
);
console.log('C2B URLs registered:', response.data);
}
// Validation endpoint — runs BEFORE payment completes
router.post('/api/mpesa/c2b/validation', async (req, res) => {
console.log('C2B Validation Request:', JSON.stringify(req.body));
const { BillRefNumber, TransAmount } = req.body;
try {
// Example: Check if the order exists and amount matches
const order = await findOrderByReference(BillRefNumber);
if (!order) {
// Reject — invalid account reference
return res.json({
ResultCode: 'C2B00012',
ResultDesc: 'Invalid Account Number',
});
}
if (Number(TransAmount) < order.totalAmount) {
// Reject — insufficient amount
return res.json({
ResultCode: 'C2B00013',
ResultDesc: 'Insufficient Amount',
});
}
// Accept the transaction
return res.json({
ResultCode: 0,
ResultDesc: 'Accepted',
});
} catch (error) {
console.error('Validation error:', error.message);
// Accept by default to avoid blocking legitimate payments
return res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
}
});
// Confirmation endpoint — runs AFTER successful payment
router.post('/api/mpesa/c2b/confirmation', async (req, res) => {
res.status(200).json({ ResultCode: 0, ResultDesc: 'Success' });
const { TransID, TransAmount, BillRefNumber, MSISDN, TransTime } = req.body;
console.log(`C2B Payment Confirmed: ${TransID} - KES ${TransAmount}`);
// Process the payment (update order, send confirmation, etc.)
await processC2BPayment({
transactionId: TransID,
amount: TransAmount,
reference: BillRefNumber,
phone: MSISDN,
timestamp: TransTime,
});
});
When should you use C2B instead of STK Push? A few scenarios:
- Recurring payments — Customers who pay you monthly via paybill (like rent or subscriptions)
- USSD-initiated payments — When customers don't have smartphones or prefer the SIM toolkit menu
- Offline-initiated payments — When customers pay at M-Pesa agents and reference your paybill number
- Backup payment method — Some customers simply prefer the traditional M-Pesa flow they're used to
From my experience building e-commerce sites here in Kenya, the best approach is to offer STK Push as the primary checkout method and support C2B as a fallback. Give customers instructions like "Alternatively, go to M-Pesa > Lipa Na M-Pesa > Pay Bill, enter business number XXXXX, account number [order ID], and amount KES X,XXX."
B2C Integration: Sending Money to Customers
B2C (Business to Customer) is the reverse flow — your business sends money to a customer's M-Pesa account. Think refunds, marketplace payouts, salary disbursements, cashback rewards, or promotion payments.
B2C has stricter security requirements than STK Push or C2B. You need to generate a security credential by encrypting your initiator password with Safaricom's public certificate. And here's a gotcha — the certificates are different for sandbox and production. Use the wrong one and you'll get cryptic error messages that tell you nothing useful.
The B2C request payload includes:
- InitiatorName — The name of the API operator (set up in your M-Pesa portal)
- SecurityCredential — Your encrypted initiator password
- CommandID — One of: SalaryPayment, BusinessPayment, or PromotionPayment
- Amount — The amount to send
- PartyA — Your shortcode (the sender)
- PartyB — The customer's phone number (the receiver)
- Remarks — A note about the transaction
- QueueTimeOutURL — Where Safaricom notifies you if the request times out in their queue
- ResultURL — Where the final result is sent
- Occasion — Optional additional info
Which CommandID should you use? BusinessPayment is the most common — it's for general disbursements. SalaryPayment is specifically for salary processing and may have different transaction limits. PromotionPayment is for promotional disbursements like cashback or rewards.
I built a marketplace platform once where sellers received automatic payouts every Friday. The B2C API handled it beautifully, but we had to build a robust queue system because you can't fire off hundreds of B2C requests simultaneously — Safaricom will throttle you. We batched them in groups of 10 with a 2-second delay between batches. The B2C API supports disbursements up to KES 150,000 per transaction to registered users, so plan your payout logic accordingly.
"In Kenya, M-Pesa isn't just a payment method — it's the financial infrastructure. Getting your integration right isn't a nice-to-have; it's the difference between a business that works and one that doesn't."
Get Kenyan Developer Insights Delivered to Your Inbox
Subscribe for practical guides on M-Pesa integration, web development, and building digital products for the Kenyan market. No fluff — just real-world tips from a team that's been shipping code in Kenya since 2014.
Transaction Status Queries and Reconciliation
Here's something that separates amateur M-Pesa integrations from production-grade ones: reconciliation.
Relying solely on callbacks is dangerous. Callbacks can fail silently — your server might be down for 30 seconds during a deployment, Safaricom's infrastructure might hiccup, a network timeout might eat the callback, or your load balancer might drop the connection. When a callback fails, you have a customer who paid but your system doesn't know about it. That's a support nightmare.
The Transaction Status API is your safety net. It lets you query Safaricom directly: "Hey, what happened with this transaction?" And you should use it systematically, not just when customers complain.
Building a Reconciliation System
The approach I recommend is a scheduled cron job that runs every 5 minutes:
- Query your database for all transactions with status "pending" that are older than 5 minutes
- For each one, call the Transaction Status API
- Update your database based on the response
- Trigger any post-payment actions for newly confirmed payments
const cron = require('node-cron');
const axios = require('axios');
const { getAccessToken } = require('./auth');
// Run every 5 minutes
cron.schedule('*/5 * * * *', async () => {
console.log('Running M-Pesa reconciliation job...');
try {
// Find pending transactions older than 5 minutes
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const pendingTransactions = await findPendingTransactionsBefore(fiveMinutesAgo);
if (pendingTransactions.length === 0) {
console.log('No pending transactions to reconcile.');
return;
}
console.log(`Reconciling ${pendingTransactions.length} pending transactions...`);
const token = await getAccessToken();
const baseUrl = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke';
for (const txn of pendingTransactions) {
try {
// Small delay between requests to avoid throttling
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await axios.post(
`${baseUrl}/mpesa/stkpushquery/v1/query`,
{
BusinessShortCode: process.env.MPESA_SHORTCODE,
Password: generatePassword(), // Same password logic as STK Push
Timestamp: generateTimestamp(),
CheckoutRequestID: txn.checkoutRequestId,
},
{
headers: { Authorization: `Bearer ${token}` },
}
);
const { ResultCode, ResultDesc } = response.data;
if (ResultCode === '0') {
// Payment was successful but callback was missed
await updateTransactionStatus(txn.id, {
status: 'completed',
reconciledAt: new Date(),
resultDesc: ResultDesc,
});
await onPaymentSuccess(txn.orderId, null);
console.log(`Reconciled: ${txn.checkoutRequestId} -> completed`);
} else if (ResultCode === '1032') {
// User cancelled the request
await updateTransactionStatus(txn.id, {
status: 'cancelled',
resultDesc: 'Request cancelled by user',
});
console.log(`Reconciled: ${txn.checkoutRequestId} -> cancelled`);
} else if (ResultCode === '1037') {
// Timeout — user didn't enter PIN in time
await updateTransactionStatus(txn.id, {
status: 'timeout',
resultDesc: 'STK Push timeout',
});
console.log(`Reconciled: ${txn.checkoutRequestId} -> timeout`);
} else {
// Other failure
await updateTransactionStatus(txn.id, {
status: 'failed',
resultCode: ResultCode,
resultDesc: ResultDesc,
});
console.log(`Reconciled: ${txn.checkoutRequestId} -> failed (${ResultCode})`);
}
} catch (queryError) {
console.error(
`Failed to query status for ${txn.checkoutRequestId}:`,
queryError.message
);
// Don't fail the whole job — continue with the next transaction
}
}
} catch (error) {
console.error('Reconciliation job failed:', error.message);
}
});
Beyond the STK Push query, Safaricom also provides the Account Balance API for checking your M-Pesa account balance programmatically — useful for dashboards and financial reporting. And the Reversal API lets you reverse erroneous transactions, though it requires additional security credentials and has specific time limits.
At the end of the day, a production-grade M-Pesa payment gateway needs multiple layers of verification. Callbacks are your primary notification, the STK Push query is your secondary check, and the Transaction Status API is your fallback for everything else. Belt, suspenders, and a safety net.
Security Best Practices for M-Pesa API Integration
When you're handling people's money, security isn't optional. A single vulnerability in your M-Pesa integration can lead to unauthorized transactions, data breaches, or financial loss. Here are the M-Pesa API security best practices I follow on every project, informed by both experience and the OWASP API Security guidelines.
1. Protect Your Credentials
Never — and I mean never — expose your consumer key, consumer secret, or passkey in client-side code, public repositories, or configuration files that get committed to Git. Use environment variables (.env files) and add .env to your .gitignore on the very first commit.
2. HTTPS Everything
All callback URLs must use HTTPS. Safaricom requires it, and it protects transaction data in transit. If you're on a budget, Let's Encrypt provides free SSL certificates. There's no excuse for HTTP in 2024, especially for payment endpoints. You should never cheap out on hosting — a slow or insecure server costs you more in lost customers than the KES 5,000 you saved.
3. Validate All Callback Data
Don't blindly trust incoming callbacks. Before updating an order as paid:
- Verify the amount matches your expected amount
- Verify the CheckoutRequestID matches a real pending transaction
- Verify the phone number matches (or at least log discrepancies)
- Check that the transaction hasn't already been processed (idempotency)
4. IP Whitelisting
If your infrastructure supports it, restrict your callback endpoints to accept requests only from Safaricom's IP ranges. This prevents spoofed callbacks from malicious actors trying to trick your system into marking orders as paid.
5. Rate Limiting
Implement rate limiting on your payment initiation endpoints. Without it, a bot could trigger thousands of STK Push requests, potentially causing issues with Safaricom and overwhelming your system.
6. Encrypt Data at Rest
M-Pesa receipt numbers, phone numbers, and transaction amounts should be stored securely. Encrypt sensitive fields in your database and ensure your database itself is properly secured.
7. Smart Logging
Log everything for debugging, but mask sensitive data. A phone number in your logs should look like 2547****5678, not the full number. This protects customer privacy and reduces your liability if logs are ever compromised.
8. B2C Certificate Management
For B2C integrations, you'll need Safaricom's public certificate to generate security credentials. Store these certificates securely, and remember — sandbox and production use different certificates. I've seen developers deploy to production with the sandbox certificate and wonder why every B2C request fails.
9. CSRF Protection
If your payment initiation happens through a web form, implement CSRF tokens. You don't want a malicious site triggering payments on behalf of your logged-in users.
Never Commit Your API Credentials to Git
Never commit your Daraja API consumer key, consumer secret, or passkey to a public Git repository. Use environment variables (.env files) and add .env to your .gitignore. Leaked M-Pesa credentials can be exploited to initiate unauthorized transactions from your business account.
If you accidentally commit credentials, rotate them immediately in the Daraja portal — don't just delete the commit, as Git history preserves it.
Going Live: The Safaricom Production Approval Process
You've built your integration, tested it thoroughly in the sandbox, and everything works perfectly. Now comes the part that makes every developer anxious — going live.
Safaricom requires a formal Go Live process. You can't just swap sandbox URLs for production URLs and call it a day. Here's what the process looks like:
- Ensure you have a registered Safaricom paybill or till number — This is a business requirement, not a developer one. Your client (or your business) needs an active M-Pesa merchant account.
- Submit your app for review through the Daraja portal — Navigate to your app, click "Go Live," and fill in the required details including your production callback URLs.
- Provide production callback URLs — These must be live, accessible HTTPS endpoints. Safaricom will verify they respond correctly.
- Wait for approval — Typically 2-7 business days, but I've seen it take up to two weeks during busy periods. Plan accordingly.
- Receive production credentials — Once approved, you'll get production consumer key and consumer secret, plus the production passkey for your shortcode.
Common Reasons for Rejection
- Callback URLs returning errors or not accessible
- Missing or incorrect shortcode information
- Incomplete application details
- Callback URLs using HTTP instead of HTTPS
Tips for Speeding Up Approval
- Double-check all URLs are accessible and responding with 200 OK before submitting
- Use a proper domain name, not an IP address or ngrok URL for production callbacks
- Have your paybill/till number details ready and accurate
- If it's taking too long, reach out to Safaricom developer support — sometimes a polite follow-up helps
One important note: sandbox and production behave differently in subtle ways. Timing, error messages, and even callback payload structures can vary slightly. After going live, do thorough testing with small amounts (KES 1 transactions) before opening the floodgates. I always tell my clients at Quest to budget an extra week after go-live for production-specific debugging.
Common Pitfalls Kenyan Developers Face (And How to Avoid Them)
After 14 years of building websites and web applications — and after completing over 85 projects on Freelancer.com — I've seen just about every M-Pesa integration mistake in the book. Here are the ones that come up most often, and how to dodge them.
1. Not Implementing Token Refresh
I've covered this already, but it deserves repeating because it's the single most common bug. Access tokens expire after 1 hour. If you're not caching and refreshing them, your integration will randomly break and you'll get "Invalid Access Token" errors that seem to appear out of nowhere.
2. Treating the STK Push Response as Payment Confirmation
The synchronous response from STK Push means "Safaricom received your request." That's it. It does NOT mean the customer paid. Wait for the callback. Always.
3. Not Handling Duplicate Callbacks
Safaricom may send the same callback more than once — especially if your server was slow to respond with 200 OK. Without idempotency checks (using the MpesaReceiptNumber as a unique key), you might process the same payment twice, credit an account twice, or fulfill an order twice.
4. Timeout Mismatches
Safaricom's STK Push processing can take up to 60 seconds (the customer has to actually see the prompt, open it, and enter their PIN). But many web servers, load balancers, or reverse proxies have default timeouts of 15-30 seconds. If your server times out before Safaricom responds, you'll get errors even though the payment might still succeed. Configure your timeout settings to at least 65 seconds for M-Pesa endpoints.
5. Hardcoding Credentials
I've inherited codebases where the consumer key and secret were hardcoded right there in the controller file. Sometimes even committed to a public GitHub repo. Use environment variables. Period.
6. No Reconciliation Mechanism
Callbacks are your primary notification, but they're not guaranteed. Without a reconciliation cron job (like the one I showed earlier), you'll have "lost" payments — customers who paid but your system never acknowledged. These turn into support tickets, chargebacks, and lost trust.
7. Sandbox-to-Production Behavioral Differences
The sandbox is great for development, but it's not a perfect replica of production. I've seen callbacks arrive in a different order, error codes that don't match documentation, and timing that's way off. Test in sandbox, but expect surprises in production.
8. Not Handling "Request Cancelled by User"
When a customer sees the STK Push prompt and dismisses it (or their phone auto-cancels it), you'll get a callback with ResultCode 1032. Many developers only handle the success case (ResultCode 0) and ignore everything else. Handle cancellations gracefully — update the pending transaction to "cancelled," and let the customer try again without creating a new order.
9. Phone Number Formatting Issues
This one is embarrassingly common and entirely preventable. The Daraja API expects phone numbers in the 254XXXXXXXXX format (12 digits). But customers will enter their number as 0712345678, +254712345678, 254 712 345 678, or some other variation. Always normalize before sending.
Phone Number Formatting Tip
Always normalize Kenyan phone numbers to the 254XXXXXXXXX format (12 digits) before sending to the Daraja API. Strip leading zeros (0712345678 → 254712345678) and plus signs (+254712345678 → 254712345678). This single validation step prevents a surprising number of failed transactions.
10. Ignoring Transaction Limits
M-Pesa has daily and per-transaction limits. The maximum STK Push transaction amount is KES 150,000 for registered users. If your platform sells high-value items, you need to handle split payments or alternative payment methods for amounts exceeding this limit. Don't let a customer get to checkout only to discover they can't complete a KES 200,000 purchase via STK Push.
From my experience, about 80% of M-Pesa integration bugs fall into these ten categories. Fix these, and you'll have a solid, production-ready integration that handles real-world conditions gracefully.
Conclusion: Build Payments That Kenyans Trust
We've covered a lot of ground in this Safaricom Daraja API tutorial — from understanding the API architecture and setting up your sandbox environment, to implementing STK Push, handling callbacks, building C2B and B2C flows, securing your integration, navigating the go-live process, and avoiding the pitfalls that trip up most developers.
Let me leave you with the key takeaways:
- Start with the sandbox, but don't trust it blindly — production will surprise you
- Implement all the safety nets from day one — idempotency, reconciliation, error handling, token refresh. Don't add these later. You'll forget, and it'll cost you.
- The callback is the source of truth, not the synchronous response. Build your entire payment flow around this principle.
- Security isn't optional when you're handling money. Follow the best practices. Protect credentials. Validate everything.
- Don't rush the go-live process — budget time for production-specific testing with real (small) transactions
And here's something this guide doesn't cover in depth but matters enormously: user experience. A technically perfect M-Pesa integration can still frustrate customers if the UX is poor. Show clear payment status feedback ("Waiting for your M-Pesa PIN..."), send SMS or email confirmations when payment succeeds, display friendly error messages when things go wrong ("Payment was not completed. Would you like to try again?"), and handle edge cases like the user's phone being off or M-Pesa being down.
M-Pesa isn't going anywhere. With over 30 million active users and growing, it's the backbone of digital commerce in Kenya. According to the GSMA's State of the Industry Report, mobile money continues to be the primary driver of financial inclusion across Sub-Saharan Africa, and Kenya leads the way. Every Kenyan business deserves a professional website with a solid M-Pesa integration — the internet is the great equalizer, and M-Pesa is the payment method that makes it all work.
If you've followed this guide, you're well on your way to building an M-Pesa payment gateway that Kenyans can trust. And if you'd rather have someone who's done this dozens of times handle it for you — well, that's exactly what we do at Quest.
Frequently Asked Questions
Need Expert M-Pesa Integration for Your Website?
Quest's development team has implemented M-Pesa payments for dozens of Kenyan businesses — from e-commerce stores to SaaS platforms to bus booking systems. Skip the trial-and-error and get a production-ready integration built by developers who've been doing this since the Daraja API launched.