Mobile money has revolutionized the financial landscape in Africa, with Kenya’s M-Pesa leading the way. For developers and businesses building digital products in East Africa, integrating M-Pesa payments isn’t just a nice-to-have feature — it’s essential.
Most tutorials show you how to send a push request. Very few show you what happens after: when webhooks arrive late, duplicate callbacks fire, or the system fails at 2 AM. This guide covers the entire journey: from basic setup to a production-ready, fault-tolerant backend.
🚀 Part 1: The Basics (Getting Started)
What is STK Push?
STK Push (Lipa Na M-Pesa Online) leverages the SIM Application Toolkit to send a prompt directly to a customer’s phone, asking them to enter their M-Pesa PIN. It eliminates the need for customers to remember paybill numbers or account numbers, significantly reducing friction.
Prerequisites
Before we dive into the code, you’ll need:
- A Safaricom Developer Account: Register at developer.safaricom.co.ke.
- M-Pesa API credentials: Consumer Key and Secret.
- Node.js and npm installed.
- A publicly accessible URL for callbacks: (We’ll use ngrok for local development).
Setting up the Project
Let’s start by creating a new Node.js project:
mkdir mpesa-integration
cd mpesa-integration
npm init -y
npm install express axios dotenv
Next, create a .env file to store your API credentials:
CONSUMER_KEY=your_consumer_key_here
CONSUMER_SECRET=your_consumer_secret_here
BUSINESS_SHORT_CODE=174379 # Default sandbox shortcode
PASS_KEY=bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919 # Default sandbox passkey
CALLBACK_URL=https://your-callback-url.com/api/mpesa/callback
⚙️ Part 2: Understanding the Core Components
Before implementing the integration, let’s understand the three critical elements that make the STK Push work.
1. Timestamp Generation
Every request to M-Pesa requires a timestamp in the format YYYYMMDDHHmmss. This helps Safaricom identify when the request was made and prevents replay attacks.
const getTimestamp = () => {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}${hours}${minutes}${seconds}`;
};
2. Password Generation
The password for STK Push is a Base64 encoded string combining your business shortcode, a passkey provided by Safaricom, and the timestamp.
const getPassword = (timestamp) => {
const shortCode = process.env.BUSINESS_SHORT_CODE;
const passKey = process.env.PASS_KEY;
const password = `${shortCode}${passKey}${timestamp}`;
return Buffer.from(password).toString('base64');
};
3. Access Token Generation
Before making any API calls, you must authenticate with Safaricom using OAuth:
const getAccessToken = async () => {
try {
const url = 'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials';
const auth = Buffer.from(`${process.env.CONSUMER_KEY}:${process.env.CONSUMER_SECRET}`).toString('base64');
const response = await axios.get(url, {
headers: { 'Authorization': `Basic ${auth}` }
});
return response.data.access_token;
} catch(error) {
console.error('Error getting access token:', error);
throw error;
}
};
🛠️ Part 3: Full Basic Implementation
Here is the complete Express.js implementation to get you started in the sandbox.
const express = require('express');
const axios = require('axios');
const dotenv = require('dotenv');
const router = express.Router();
dotenv.config();
// Helper functions (Timestamp, Password, Token)
const getTimestamp = () => {
const date = new Date();
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}${String(date.getSeconds()).padStart(2, '0')}`;
};
const getPassword = (timestamp) => {
return Buffer.from(`${process.env.BUSINESS_SHORT_CODE}${process.env.PASS_KEY}${timestamp}`).toString('base64');
};
const getAccessToken = async () => {
const url = 'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials';
const auth = Buffer.from(`${process.env.CONSUMER_KEY}:${process.env.CONSUMER_SECRET}`).toString('base64');
const response = await axios.get(url, { headers: { 'Authorization': `Basic ${auth}` } });
return response.data.access_token;
};
// Route to initiate STK Push
router.post('/stk-push', async (req, res) => {
try {
const { phoneNumber, amount } = req.body;
if (!phoneNumber || !amount) return res.status(400).json({ error: 'Phone number and amount are required' });
let formattedPhone = phoneNumber.startsWith('0') ? `254${phoneNumber.slice(1)}` : (phoneNumber.startsWith('+254') ? phoneNumber.slice(1) : phoneNumber);
const accessToken = await getAccessToken();
const timestamp = getTimestamp();
const password = getPassword(timestamp);
const url = 'https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest';
const data = {
BusinessShortCode: process.env.BUSINESS_SHORT_CODE,
Password: password,
Timestamp: timestamp,
TransactionType: 'CustomerPayBillOnline',
Amount: amount,
PartyA: formattedPhone,
PartyB: process.env.BUSINESS_SHORT_CODE,
PhoneNumber: formattedPhone,
CallBackURL: process.env.CALLBACK_URL,
AccountReference: 'Test Payment',
TransactionDesc: 'Test Payment'
};
const response = await axios.post(url, data, {
headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }
});
return res.json({ success: true, data: response.data });
} catch (error) {
return res.status(500).json({ success: false, error: error.message });
}
});
// Callback route to receive STK Push response
router.post('/callback', (req, res) => {
console.log('STK Callback response:', JSON.stringify(req.body));
const callbackData = req.body.Body.stkCallback;
res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
if (callbackData.ResultCode === 0) {
console.log('Payment successful');
} else {
console.log('Payment failed:', callbackData.ResultDesc);
}
});
module.exports = router;
Testing the Integration
To test this locally, use ngrok:
npm install -g ngrok
ngrok http 3000
Update your .env with the ngrok URL, then make a POST request to /api/mpesa/stk-push with:
{
"phoneNumber": "254708374149",
"amount": "1"
}
⚠️ Part 4: The "Production Reality" (Advanced Logic)
If you are building for a real Tanzanian business, the basic code above is not enough. You will encounter "The 2 AM Nightmare" where webhooks fail or duplicate.
Why Production is Different
- Webhooks can arrive late: Sometimes 30 seconds, sometimes 5 minutes.
- Duplicate callbacks: The same transaction can trigger your webhook multiple times.
- Silent failures: Payment succeeds on M-Pesa's side, but your server never receives the callback.
The Production Stack
For high-reliability, I recommend:
- Runtime: Node.js with NestJS (TypeScript).
- Database: PostgreSQL (to log every single transaction state).
- Queue: Redis + Bull (to process webhooks asynchronously).
Production-Grade Implementation Logic
1. The "Fast-Response" Webhook
M-Pesa will retry if you are slow. You must respond within 5 seconds.
@Post('webhook/mpesa')
async handleMpesaWebhook(@Body() payload: MpesaCallbackDto) {
// Log raw payload immediately so you never lose data
await this.webhookLogRepo.save({ rawPayload: JSON.stringify(payload) });
// Process the logic in the background (Async)
this.mpesaService.processCallback(payload);
// Respond immediately to Safaricom
return { ResultCode: 0, ResultDesc: 'Accepted' };
}
2. Handling Idempotency (The "Double Payment" Fix)
Never process a payment twice. Always verify if the MpesaReceiptNumber already exists.
async processCallback(payload: MpesaCallbackDto) {
const existing = await this.transactionRepo.findOne({
where: { mpesaReceiptNumber: payload.MpesaReceiptNumber }
});
if (existing?.status === 'completed') return; // Exit if already processed
await this.transactionRepo.update(
{ checkoutRequestId: payload.CheckoutRequestID },
{ status: 'completed', mpesaReceiptNumber: payload.MpesaReceiptNumber }
);
}
🛡️ Security Best Practices
- IP Whitelisting: Only allow requests to your
/callbackendpoint from official Safaricom/Vodacom IP ranges. - Verify Amounts: Never trust the
Amountsent in the callback. Compare it against the amount you originally requested in your database. - Timeout Handling: If no callback arrives after 15 minutes, mark the transaction as "Expired" to avoid locking user accounts.
- HTTPS Only: M-Pesa will not call HTTP endpoints in production.
Conclusion
A production M-Pesa integration is about 80% error handling and only 20% the "happy path." Build for failure from day one.
Want to skip the setup? I have a production-ready M-Pesa Webhook Handler Template. Download my Template or Book a free consultation and let's review your architecture together.