In this tutorial, I’ll walk you through integrating M-Pesa’s Lipa na M-Pesa Online (STK Push) into your Node.js backend. This guide covers everything from setup to implementation with a clear folder structure.
Prerequisites
Before we begin, ensure you have:
- Node.js installed (v14 or higher)
- An M-Pesa Daraja API account (sandbox or production)
- Basic knowledge of Express.js and REST APIs
Project Structure
mpesa-integration/ ├── src/ │ ├── config/ │ │ └── mpesa.config.js │ ├── controllers/ │ │ └── mpesa.controller.js │ ├── routes/ │ │ └── mpesa.routes.js │ └── utils/ │ └── security.js ├── .env ├── .gitignore ├── package.json └── server.js
Step 1: Initialize Project
Create a new directory and initialize your Node.js project:
mkdir mpesa-integration cd mpesa-integration npm init -y
Step 2: Install Dependencies
Install the required packages:
npm install express axios dotenv cors npm install -D nodemon
Step 3: Environment Variables
Create a .env file in your root directory:
# Server Configuration PORT=3000 NODE_ENV=development # M-Pesa Daraja API Credentials (Sandbox) MPESA_CONSUMER_KEY=your_consumer_key_here MPESA_CONSUMER_SECRET=your_consumer_secret_here MPESA_BUSINESS_SHORTCODE=174379 MPESA_PASSKEY=your_passkey_here MPESA_CALLBACK_URL=https://your-domain.com/callback MPESA_ENVIRONMENT=sandbox # or 'production' # Security MPESA_AUTH_TOKEN_ENCRYPTION_KEY=your_32_char_encryption_key_here
Important:
- Get your credentials from Safaricom Daraja Portal
- For sandbox, use shortcode
174379 - Generate a 32-character encryption key for token security
Step 4: Create Configuration File
Create src/config/mpesa.config.js:
require('dotenv').config();
const mpesaConfig = {
// API Credentials
consumerKey: process.env.MPESA_CONSUMER_KEY,
consumerSecret: process.env.MPESA_CONSUMER_SECRET,
// STK Push Configuration
businessShortCode: process.env.MPESA_BUSINESS_SHORTCODE,
passkey: process.env.MPESA_PASSKEY,
// URLs
baseURL: process.env.MPESA_ENVIRONMENT === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke',
// Endpoints
endpoints: {
oauth: '/oauth/v1/generate?grant_type=client_credentials',
stkPush: '/mpesa/stkpush/v1/processrequest',
stkQuery: '/mpesa/stkpushquery/v1/query'
},
// Callback URL
callbackURL: process.env.MPESA_CALLBACK_URL,
// Transaction Type
transactionType: 'CustomerPayBillOnline', // or 'CustomerBuyGoodsOnline'
// Environment
environment: process.env.MPESA_ENVIRONMENT || 'sandbox'
};
// Validation
const requiredFields = ['consumerKey', 'consumerSecret', 'businessShortCode', 'passkey'];
requiredFields.forEach(field => {
if (!mpesaConfig[field]) {
throw new Error(`Missing required M-Pesa configuration: ${field}`);
}
});
module.exports = mpesaConfig;
Step 5: Security Utility
Create src/utils/security.js for token encryption:
const crypto = require('crypto');
const encryptionKey = process.env.MPESA_AUTH_TOKEN_ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length !== 32) {
throw new Error('MPESA_AUTH_TOKEN_ENCRYPTION_KEY must be 32 characters long');
}
const algorithm = 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const security = {
// Encrypt sensitive data
encrypt(text) {
const cipher = crypto.createCipheriv(algorithm, Buffer.from(encryptionKey), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return {
iv: iv.toString('hex'),
encryptedData: encrypted.toString('hex')
};
},
// Decrypt sensitive data
decrypt(encryptedData) {
const iv = Buffer.from(encryptedData.iv, 'hex');
const encryptedText = Buffer.from(encryptedData.encryptedData, 'hex');
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(encryptionKey), iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
},
// Generate unique transaction reference
generateTransactionReference() {
const timestamp = Date.now().toString();
const random = Math.random().toString(36).substring(2, 10);
return `MPESA${timestamp}${random}`.toUpperCase();
}
};
module.exports = security;
Step 6: M-Pesa Controller
Create src/controllers/mpesa.controller.js:
const axios = require('axios');
const mpesaConfig = require('../config/mpesa.config');
const security = require('../utils/security');
// Cache for access token (in production, use Redis or similar)
let accessTokenCache = {
token: null,
expiry: null
};
class MpesaController {
// Get OAuth Access Token
async getAccessToken() {
try {
// Check if token is still valid (expires in 3599 seconds)
if (accessTokenCache.token && accessTokenCache.expiry > Date.now()) {
return accessTokenCache.token;
}
const auth = Buffer.from(`${mpesaConfig.consumerKey}:${mpesaConfig.consumerSecret}`).toString('base64');
const response = await axios.get(
`${mpesaConfig.baseURL}${mpesaConfig.endpoints.oauth}`,
{
headers: {
'Authorization': `Basic ${auth}`
}
}
);
const tokenData = response.data;
accessTokenCache = {
token: tokenData.access_token,
expiry: Date.now() + (tokenData.expires_in * 1000) - 60000 // Subtract 1 minute for safety
};
// Encrypt and store token (optional)
const encryptedToken = security.encrypt(tokenData.access_token);
return tokenData.access_token;
} catch (error) {
console.error('Error getting access token:', error.response?.data || error.message);
throw new Error('Failed to get access token');
}
}
// Generate Password for STK Push
generatePassword() {
const timestamp = this.getTimestamp();
const businessShortCode = mpesaConfig.businessShortCode;
const passkey = mpesaConfig.passkey;
const dataToEncode = businessShortCode + passkey + timestamp;
const password = Buffer.from(dataToEncode).toString('base64');
return { password, timestamp };
}
// Get current timestamp in yyyymmddhhiiss format
getTimestamp() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}${hour}${minute}${second}`;
}
// Initiate STK Push
async initiateSTKPush(req, res) {
try {
const { phoneNumber, amount, accountReference, transactionDesc } = req.body;
// Validate input
if (!phoneNumber || !amount) {
return res.status(400).json({
success: false,
message: 'Phone number and amount are required'
});
}
// Format phone number (remove leading 0 or +254, add 254)
const formattedPhone = this.formatPhoneNumber(phoneNumber);
// Get access token
const accessToken = await this.getAccessToken();
// Generate password and timestamp
const { password, timestamp } = this.generatePassword();
// Generate unique transaction reference
const transactionReference = security.generateTransactionReference();
// Prepare STK Push request
const stkPushRequest = {
BusinessShortCode: mpesaConfig.businessShortCode,
Password: password,
Timestamp: timestamp,
TransactionType: mpesaConfig.transactionType,
Amount: Math.round(amount), // Convert to integer
PartyA: formattedPhone,
PartyB: mpesaConfig.businessShortCode,
PhoneNumber: formattedPhone,
CallBackURL: mpesaConfig.callbackURL,
AccountReference: accountReference || transactionReference.substring(0, 12),
TransactionDesc: transactionDesc || 'Payment for services'
};
// Make STK Push request
const response = await axios.post(
`${mpesaConfig.baseURL}${mpesaConfig.endpoints.stkPush}`,
stkPushRequest,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
const responseData = response.data;
if (responseData.ResponseCode === '0') {
// Success - STK Push initiated
return res.status(200).json({
success: true,
message: 'STK Push initiated successfully',
data: {
checkoutRequestID: responseData.CheckoutRequestID,
merchantRequestID: responseData.MerchantRequestID,
customerMessage: responseData.CustomerMessage,
transactionReference: transactionReference
}
});
} else {
// STK Push failed
return res.status(400).json({
success: false,
message: responseData.ResponseDescription || 'Failed to initiate STK Push',
errorCode: responseData.ResponseCode
});
}
} catch (error) {
console.error('STK Push Error:', error.response?.data || error.message);
return res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
// Format phone number to 2547XXXXXXXX
formatPhoneNumber(phone) {
// Remove any non-digit characters
let cleaned = phone.toString().replace(/\D/g, '');
// Handle various formats
if (cleaned.startsWith('254')) {
return cleaned;
} else if (cleaned.startsWith('0')) {
return '254' + cleaned.substring(1);
} else if (cleaned.startsWith('7') && cleaned.length === 9) {
return '254' + cleaned;
} else {
// Assume it's already in international format
return cleaned;
}
}
// Handle M-Pesa Callback
async handleCallback(req, res) {
try {
const callbackData = req.body;
// Log the callback data (for debugging)
console.log('M-Pesa Callback Received:', JSON.stringify(callbackData, null, 2));
// Check if this is a valid callback
if (!callbackData.Body || !callbackData.Body.stkCallback) {
return res.status(400).json({
success: false,
message: 'Invalid callback data'
});
}
const stkCallback = callbackData.Body.stkCallback;
const resultCode = stkCallback.ResultCode;
const resultDesc = stkCallback.ResultDesc;
const checkoutRequestID = stkCallback.CheckoutRequestID;
const merchantRequestID = stkCallback.MerchantRequestID;
// Process based on result code
if (resultCode === 0) {
// Payment successful
const callbackMetadata = stkCallback.CallbackMetadata;
const items = callbackMetadata.Item || [];
// Extract payment details
const paymentDetails = {};
items.forEach(item => {
paymentDetails[item.Name] = item.Value;
});
// Here you would:
// 1. Update your database with successful payment
// 2. Send confirmation email/SMS
// 3. Fulfill order/service
console.log('Payment Successful:', {
checkoutRequestID,
merchantRequestID,
amount: paymentDetails.Amount,
mpesaReceiptNumber: paymentDetails.MpesaReceiptNumber,
phoneNumber: paymentDetails.PhoneNumber,
transactionDate: paymentDetails.TransactionDate
});
// Respond to M-Pesa that callback was received successfully
return res.status(200).json({
ResultCode: 0,
ResultDesc: 'Callback processed successfully'
});
} else {
// Payment failed or was cancelled
console.log('Payment Failed:', {
checkoutRequestID,
merchantRequestID,
resultCode,
resultDesc
});
// Here you would:
// 1. Update your database with failed payment
// 2. Notify user of failure
// Still respond with success to acknowledge receipt
return res.status(200).json({
ResultCode: 0,
ResultDesc: 'Callback processed successfully'
});
}
} catch (error) {
console.error('Callback Processing Error:', error);
// Even if there's an error, respond with success to prevent M-Pesa retries
return res.status(200).json({
ResultCode: 0,
ResultDesc: 'Callback received'
});
}
}
// Query STK Push Status
async querySTKStatus(req, res) {
try {
const { checkoutRequestID } = req.body;
if (!checkoutRequestID) {
return res.status(400).json({
success: false,
message: 'Checkout Request ID is required'
});
}
const accessToken = await this.getAccessToken();
const { password, timestamp } = this.generatePassword();
const queryRequest = {
BusinessShortCode: mpesaConfig.businessShortCode,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestID
};
const response = await axios.post(
`${mpesaConfig.baseURL}${mpesaConfig.endpoints.stkQuery}`,
queryRequest,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
const responseData = response.data;
return res.status(200).json({
success: true,
data: responseData
});
} catch (error) {
console.error('STK Query Error:', error.response?.data || error.message);
return res.status(500).json({
success: false,
message: 'Failed to query STK status'
});
}
}
}
module.exports = new MpesaController();
Step 7: Create Routes
Create src/routes/mpesa.routes.js:
const express = require('express');
const router = express.Router();
const mpesaController = require('../controllers/mpesa.controller');
// Middleware to validate requests
const validateSTKPushRequest = (req, res, next) => {
const { phoneNumber, amount } = req.body;
if (!phoneNumber) {
return res.status(400).json({
success: false,
message: 'Phone number is required'
});
}
if (!amount || isNaN(amount) || amount <= 0) {
return res.status(400).json({
success: false,
message: 'Valid amount is required'
});
}
next();
};
// Routes
router.post('/stkpush', validateSTKPushRequest, mpesaController.initiateSTKPush);
router.post('/callback', mpesaController.handleCallback);
router.post('/query-status', mpesaController.querySTKStatus);
module.exports = router;
Step 8: Main Server File
Create server.js in the root directory:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const mpesaRoutes = require('./src/routes/mpesa.routes');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging middleware
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
next();
});
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
service: 'M-Pesa Integration API'
});
});
// M-Pesa routes
app.use('/api/mpesa', mpesaRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
message: 'Endpoint not found'
});
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Server Error:', err);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
console.log(`M-Pesa Mode: ${process.env.MPESA_ENVIRONMENT}`);
});
module.exports = app;
Step 9: Update package.json
Update your package.json with these scripts:
{
"name": "mpesa-integration",
"version": "1.0.0",
"description": "M-Pesa STK Push Integration",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["mpesa", "stk-push", "nodejs", "payment"],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
Step 10: Test the Integration
- Start your server:
npm run dev
- Test STK Push with curl or Postman:
curl --location 'http://localhost:3000/api/mpesa/stkpush' \
--header 'Content-Type: application/json' \
--data '{
"phoneNumber": "0712345678",
"amount": 100,
"accountReference": "ORDER123",
"transactionDesc": "Payment for goods"
}'
- For local testing callback, use ngrok:
# Install ngrok npm install -g ngrok # Start ngrok tunnel ngrok http 3000 # Update your .env with the ngrok URL MPESA_CALLBACK_URL=https://your-ngrok-url.ngrok.io/api/mpesa/callback
Testing with M-Pesa Sandbox
- Use test credentials from Daraja portal
- Test phone number:
254708374149 - Test PIN:
4100 - Amount: Use whole numbers (1-1000)
Important Notes
- Security: Never expose your credentials. Use environment variables and consider encrypting sensitive data.
- Callback URL: Must be publicly accessible. M-Pesa cannot call localhost.
- Error Handling: Implement proper error handling and logging.
- Database: In production, store transaction details in a database.
- Timeouts: STK Push expires after a few minutes. Implement timeout handling.
- Duplicate Payments: Use transaction references to prevent duplicate processing.
Production Considerations
- Use Redis or similar for token caching
- Implement request rate limiting
- Add comprehensive logging
- Set up monitoring and alerts
- Use HTTPS in production
- Implement webhook signature verification
This implementation provides a solid foundation for M-Pesa STK Push integration. Remember to handle edge cases, implement proper logging, and secure your application before going to production.






















Leave a Comment
Your email address will not be published. Required fields are marked with *