Integrating M-Pesa STK Push (Lipa na M-Pesa Online) in Node.js

Integrating M-Pesa STK Push (Lipa na M-Pesa Online) in Node.js

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

  1. Start your server:
npm run dev
  1. 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"
}'
  1. 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

  1. Use test credentials from Daraja portal
  2. Test phone number: 254708374149
  3. Test PIN: 4100
  4. Amount: Use whole numbers (1-1000)

Important Notes

  1. Security: Never expose your credentials. Use environment variables and consider encrypting sensitive data.
  2. Callback URL: Must be publicly accessible. M-Pesa cannot call localhost.
  3. Error Handling: Implement proper error handling and logging.
  4. Database: In production, store transaction details in a database.
  5. Timeouts: STK Push expires after a few minutes. Implement timeout handling.
  6. Duplicate Payments: Use transaction references to prevent duplicate processing.

Production Considerations

  1. Use Redis or similar for token caching
  2. Implement request rate limiting
  3. Add comprehensive logging
  4. Set up monitoring and alerts
  5. Use HTTPS in production
  6. 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.

Posts Carousel

Leave a Comment

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

Latest Posts

Most Commented

Featured Videos