Denis - Taganya.devBook Free Consultation
← Back to Blog
Article·May 5, 2026

How I Built a Production-Ready M-Pesa Integration Backend in Node.js

A step-by-step guide covering webhook handling, security, reconciliation, and common mistakes to avoid when integrating M-Pesa in Tanzania.

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:

  1. A Safaricom Developer Account: Register at developer.safaricom.co.ke.
  2. M-Pesa API credentials: Consumer Key and Secret.
  3. Node.js and npm installed.
  4. 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

  1. IP Whitelisting: Only allow requests to your /callback endpoint from official Safaricom/Vodacom IP ranges.
  2. Verify Amounts: Never trust the Amount sent in the callback. Compare it against the amount you originally requested in your database.
  3. Timeout Handling: If no callback arrives after 15 minutes, mark the transaction as "Expired" to avoid locking user accounts.
  4. 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.

Found this useful?

If you need help applying any of this to your own project, I offer free 30-minute consultations.