Complete Guide: Integrating M-Pesa STK Push (Lipa na M-Pesa Online) with Node.js and TypeScript

Complete Guide: Integrating M-Pesa STK Push (Lipa na M-Pesa Online) with Node.js and TypeScript

In this comprehensive guide, I’ll walk you through integrating M-Pesa’s Lipa na M-Pesa Online (STK Push) using Node.js with TypeScript. We’ll build a production-ready, type-safe API with proper error handling, security, and best practices.

Why TypeScript for M-Pesa Integration?

TypeScript adds type safety to your M-Pesa integration, helping you:

  • Catch errors at compile time
  • Improve code maintainability
  • Get better IDE support
  • Ensure data consistency with M-Pesa API contracts

Project Overview

We’ll build a complete M-Pesa STK Push system with:

  • Type-safe configuration management
  • Secure credential handling
  • Comprehensive error handling
  • Callback processing
  • Transaction status queries
  • Proper folder structure for scalability

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ and npm/yarn/pnpm installed
  • TypeScript 5+ installed globally (npm install -g typescript)
  • M-Pesa Daraja API credentials (get from Safaricom Daraja Portal)
  • Basic knowledge of Express, TypeScript, and REST APIs

Project Structure

mpesa-typescript-integration/
├── src/
│   ├── config/
│   │   ├── index.ts
│   │   └── mpesa.config.ts
│   ├── controllers/
│   │   └── mpesa.controller.ts
│   ├── interfaces/
│   │   ├── mpesa.interface.ts
│   │   └── response.interface.ts
│   ├── middlewares/
│   │   ├── error.middleware.ts
│   │   ├── validation.middleware.ts
│   │   └── logger.middleware.ts
│   ├── routes/
│   │   └── mpesa.routes.ts
│   ├── services/
│   │   ├── mpesa.service.ts
│   │   └── cache.service.ts
│   ├── utils/
│   │   ├── helpers.ts
│   │   ├── security.ts
│   │   └── logger.ts
│   ├── types/
│   │   └── express.d.ts
│   └── app.ts
├── tests/
│   └── mpesa.test.ts
├── .env
├── .env.example
├── .gitignore
├── .eslintrc.js
├── .prettierrc
├── tsconfig.json
├── package.json
└── README.md

Step 1: Initialize TypeScript Project

Create project folder and initialize:

mkdir mpesa-typescript-integration
cd mpesa-typescript-integration
npm init -y

Step 2: Install Dependencies

Install production dependencies:

npm install express axios dotenv cors bcryptjs jsonwebtoken winston winston-daily-rotate-file

Install development dependencies:

npm install -D typescript @types/node @types/express @types/cors @types/bcryptjs @types/jsonwebtoken @types/jest jest ts-jest supertest nodemon ts-node eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin

Step 3: Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "types": ["node", "jest"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Step 4: Environment Configuration

Create .env.example:

# Server Configuration
NODE_ENV=development
PORT=3000

# M-Pesa Daraja API Credentials
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/api/mpesa/callback
MPESA_ENVIRONMENT=sandbox

# Security
JWT_SECRET=your_jwt_secret_key_here
ENCRYPTION_KEY=your_32_char_encryption_key_here
TOKEN_EXPIRY=1h

# Logging
LOG_LEVEL=info

Create your .env file with actual values.

Step 5: Create Type Definitions

Create src/types/express.d.ts:

import { IUser } from '../interfaces/user.interface';

declare global {
  namespace Express {
    interface Request {
      user?: IUser;
      requestId?: string;
    }
  }
}

export {};

Step 6: Create Interfaces

Create src/interfaces/mpesa.interface.ts:

export interface IMpesaConfig {
  consumerKey: string;
  consumerSecret: string;
  businessShortCode: string;
  passkey: string;
  callbackURL: string;
  environment: 'sandbox' | 'production';
  transactionType: 'CustomerPayBillOnline' | 'CustomerBuyGoodsOnline';
}

export interface IStkPushRequest {
  phoneNumber: string;
  amount: number;
  accountReference?: string;
  transactionDesc?: string;
}

export interface IStkPushResponse {
  success: boolean;
  message: string;
  data?: {
    checkoutRequestID: string;
    merchantRequestID: string;
    customerMessage: string;
    transactionReference: string;
  };
  errorCode?: string;
}

export interface ICallbackData {
  Body: {
    stkCallback: {
      ResultCode: number;
      ResultDesc: string;
      MerchantRequestID: string;
      CheckoutRequestID: string;
      CallbackMetadata?: {
        Item: Array<{
          Name: string;
          Value: string | number;
        }>;
      };
    };
  };
}

export interface IAccessTokenCache {
  token: string;
  expiry: number;
}

export interface IEncryptedData {
  iv: string;
  encryptedData: string;
}

export interface IStkQueryRequest {
  checkoutRequestID: string;
}

export interface IPaymentDetails {
  Amount?: number;
  MpesaReceiptNumber?: string;
  PhoneNumber?: string;
  TransactionDate?: string;
}

export interface IValidationResult {
  isValid: boolean;
  errors?: string[];
}

Create src/interfaces/response.interface.ts:

export interface IApiResponse<T = any> {
  success: boolean;
  message: string;
  data?: T;
  error?: string;
  timestamp: string;
  requestId?: string;
}

Step 7: Create Configuration Files

Create src/config/mpesa.config.ts:

import { IMpesaConfig } from '../interfaces/mpesa.interface';
import dotenv from 'dotenv';

dotenv.config();

class MpesaConfig implements IMpesaConfig {
  public consumerKey: string;
  public consumerSecret: string;
  public businessShortCode: string;
  public passkey: string;
  public callbackURL: string;
  public environment: 'sandbox' | 'production';
  public transactionType: 'CustomerPayBillOnline' | 'CustomerBuyGoodsOnline';

  private static instance: MpesaConfig;

  private constructor() {
    this.consumerKey = this.getRequiredEnv('MPESA_CONSUMER_KEY');
    this.consumerSecret = this.getRequiredEnv('MPESA_CONSUMER_SECRET');
    this.businessShortCode = this.getRequiredEnv('MPESA_BUSINESS_SHORTCODE');
    this.passkey = this.getRequiredEnv('MPESA_PASSKEY');
    this.callbackURL = this.getRequiredEnv('MPESA_CALLBACK_URL');
    this.environment = this.getEnv('MPESA_ENVIRONMENT', 'sandbox') as 'sandbox' | 'production';
    this.transactionType = 'CustomerPayBillOnline';

    this.validateConfig();
  }

  public static getInstance(): MpesaConfig {
    if (!MpesaConfig.instance) {
      MpesaConfig.instance = new MpesaConfig();
    }
    return MpesaConfig.instance;
  }

  private getRequiredEnv(key: string): string {
    const value = process.env[key];
    if (!value) {
      throw new Error(`Missing required environment variable: ${key}`);
    }
    return value;
  }

  private getEnv(key: string, defaultValue: string): string {
    return process.env[key] || defaultValue;
  }

  private validateConfig(): void {
    const errors: string[] = [];

    if (!/^\d{5,7}$/.test(this.businessShortCode)) {
      errors.push('Business short code must be 5-7 digits');
    }

    if (!this.callbackURL.startsWith('https://') && this.environment === 'production') {
      errors.push('Callback URL must use HTTPS in production');
    }

    if (!['sandbox', 'production'].includes(this.environment)) {
      errors.push('Environment must be either "sandbox" or "production"');
    }

    if (errors.length > 0) {
      throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
    }
  }

  public getBaseURL(): string {
    return this.environment === 'production'
      ? 'https://api.safaricom.co.ke'
      : 'https://sandbox.safaricom.co.ke';
  }

  public getEndpoints() {
    return {
      oauth: '/oauth/v1/generate?grant_type=client_credentials',
      stkPush: '/mpesa/stkpush/v1/processrequest',
      stkQuery: '/mpesa/stkpushquery/v1/query',
    };
  }

  public isSandbox(): boolean {
    return this.environment === 'sandbox';
  }
}

export default MpesaConfig.getInstance();

Create src/config/index.ts:

import mpesaConfig from './mpesa.config';

export { mpesaConfig };

Step 8: Create Utility Functions

Create src/utils/helpers.ts:

import { IValidationResult } from '../interfaces/mpesa.interface';

export class Helpers {
  static formatPhoneNumber(phone: string): string {
    // Remove any non-digit characters
    let cleaned = phone.replace(/\D/g, '');

    // Handle various phone number 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 if (cleaned.startsWith('1') && cleaned.length === 9) {
      return '254' + cleaned;
    } else {
      throw new Error(`Invalid phone number format: ${phone}`);
    }
  }

  static getTimestamp(): string {
    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}`;
  }

  static generateTransactionReference(prefix = 'MPESA'): string {
    const timestamp = Date.now().toString();
    const random = Math.random().toString(36).substring(2, 10).toUpperCase();
    return `${prefix}${timestamp.substring(5)}${random}`;
  }

  static validateStkPushRequest(
    phoneNumber: string,
    amount: number
  ): IValidationResult {
    const errors: string[] = [];

    if (!phoneNumber || phoneNumber.trim() === '') {
      errors.push('Phone number is required');
    } else {
      try {
        this.formatPhoneNumber(phoneNumber);
      } catch {
        errors.push('Invalid phone number format');
      }
    }

    if (!amount || isNaN(amount) || amount <= 0) {
      errors.push('Amount must be a positive number');
    } else if (amount < 1 || amount > 150000) {
      errors.push('Amount must be between 1 and 150,000');
    } else if (!Number.isInteger(amount)) {
      errors.push('Amount must be a whole number');
    }

    return {
      isValid: errors.length === 0,
      errors: errors.length > 0 ? errors : undefined,
    };
  }

  static delay(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

Create src/utils/security.ts:

import crypto from 'crypto';
import { IEncryptedData } from '../interfaces/mpesa.interface';

export class Security {
  private static readonly ALGORITHM = 'aes-256-gcm';
  private static readonly KEY_LENGTH = 32;
  private static readonly IV_LENGTH = 16;
  private static readonly SALT_LENGTH = 64;
  private static readonly TAG_LENGTH = 16;

  private static getEncryptionKey(): Buffer {
    const encryptionKey = process.env.ENCRYPTION_KEY;

    if (!encryptionKey || encryptionKey.length !== 32) {
      throw new Error('ENCRYPTION_KEY must be exactly 32 characters long');
    }

    return Buffer.from(encryptionKey, 'utf-8');
  }

  static encrypt(text: string): IEncryptedData {
    try {
      const key = this.getEncryptionKey();
      const iv = crypto.randomBytes(this.IV_LENGTH);
      const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv);

      let encrypted = cipher.update(text, 'utf8', 'hex');
      encrypted += cipher.final('hex');

      const tag = cipher.getAuthTag();

      return {
        iv: iv.toString('hex'),
        encryptedData: encrypted + tag.toString('hex'),
      };
    } catch (error) {
      throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  }

  static decrypt(encryptedData: IEncryptedData): string {
    try {
      const key = this.getEncryptionKey();
      const iv = Buffer.from(encryptedData.iv, 'hex');
      const encryptedText = Buffer.from(encryptedData.encryptedData, 'hex');

      // Extract tag (last 16 bytes)
      const tag = encryptedText.subarray(encryptedText.length - this.TAG_LENGTH);
      const encrypted = encryptedText.subarray(0, encryptedText.length - this.TAG_LENGTH);

      const decipher = crypto.createDecipheriv(this.ALGORITHM, key, iv);
      decipher.setAuthTag(tag);

      let decrypted = decipher.update(encrypted, undefined, 'utf8');
      decrypted += decipher.final('utf8');

      return decrypted;
    } catch (error) {
      throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  }

  static hashString(text: string): string {
    return crypto.createHash('sha256').update(text).digest('hex');
  }

  static generateRandomString(length: number): string {
    return crypto.randomBytes(Math.ceil(length / 2))
      .toString('hex')
      .slice(0, length);
  }

  static validateSignature(data: string, signature: string): boolean {
    const expectedSignature = this.hashString(data + this.getEncryptionKey().toString('hex'));
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  }
}

Create src/utils/logger.ts:

import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';

const { combine, timestamp, printf, colorize, json } = winston.format;

const logFormat = printf(({ level, message, timestamp, requestId }) => {
  return `[${timestamp}] ${requestId ? `[${requestId}] ` : ''}${level}: ${message}`;
});

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json()
  ),
  transports: [
    new winston.transports.Console({
      format: combine(
        colorize(),
        timestamp({ format: 'HH:mm:ss' }),
        logFormat
      ),
    }),
    new DailyRotateFile({
      filename: 'logs/application-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '14d',
    }),
    new DailyRotateFile({
      filename: 'logs/error-%DATE%.log',
      level: 'error',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '30d',
    }),
  ],
  exceptionHandlers: [
    new DailyRotateFile({
      filename: 'logs/exceptions-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '30d',
    }),
  ],
});

export default logger;

Step 9: Create Services

Create src/services/cache.service.ts:

import { IAccessTokenCache } from '../interfaces/mpesa.interface';
import logger from '../utils/logger';

export class CacheService {
  private static instance: CacheService;
  private cache: Map<string, IAccessTokenCache>;
  private readonly DEFAULT_TTL = 3500 * 1000; // 58 minutes in milliseconds

  private constructor() {
    this.cache = new Map();
    this.startCleanupInterval();
  }

  public static getInstance(): CacheService {
    if (!CacheService.instance) {
      CacheService.instance = new CacheService();
    }
    return CacheService.instance;
  }

  public set(key: string, value: string, ttl = this.DEFAULT_TTL): void {
    const expiry = Date.now() + ttl;
    this.cache.set(key, { token: value, expiry });
    logger.debug(`Cache set for key: ${key}, expires at: ${new Date(expiry).toISOString()}`);
  }

  public get(key: string): string | null {
    const item = this.cache.get(key);

    if (!item) {
      logger.debug(`Cache miss for key: ${key}`);
      return null;
    }

    if (Date.now() > item.expiry) {
      this.cache.delete(key);
      logger.debug(`Cache expired for key: ${key}`);
      return null;
    }

    logger.debug(`Cache hit for key: ${key}`);
    return item.token;
  }

  public delete(key: string): void {
    this.cache.delete(key);
    logger.debug(`Cache deleted for key: ${key}`);
  }

  public clear(): void {
    this.cache.clear();
    logger.info('Cache cleared');
  }

  private startCleanupInterval(): void {
    setInterval(() => {
      const now = Date.now();
      let deletedCount = 0;

      for (const [key, value] of this.cache.entries()) {
        if (now > value.expiry) {
          this.cache.delete(key);
          deletedCount++;
        }
      }

      if (deletedCount > 0) {
        logger.debug(`Cache cleanup removed ${deletedCount} expired items`);
      }
    }, 60000); // Run every minute
  }

  public getStats(): { size: number; keys: string[] } {
    return {
      size: this.cache.size,
      keys: Array.from(this.cache.keys()),
    };
  }
}

Create src/services/mpesa.service.ts:

import axios, { AxiosError } from 'axios';
import { mpesaConfig } from '../config';
import { Security } from '../utils/security';
import { Helpers } from '../utils/helpers';
import { CacheService } from './cache.service';
import logger from '../utils/logger';
import {
  IStkPushRequest,
  IStkPushResponse,
  ICallbackData,
  IPaymentDetails,
} from '../interfaces/mpesa.interface';

export class MpesaService {
  private cacheService: CacheService;
  private readonly CACHE_KEY = 'mpesa_access_token';

  constructor() {
    this.cacheService = CacheService.getInstance();
  }

  public async getAccessToken(): Promise<string> {
    try {
      // Check cache first
      const cachedToken = this.cacheService.get(this.CACHE_KEY);
      if (cachedToken) {
        logger.debug('Using cached access token');
        return cachedToken;
      }

      const auth = Buffer.from(
        `${mpesaConfig.consumerKey}:${mpesaConfig.consumerSecret}`
      ).toString('base64');

      const response = await axios.get(
        `${mpesaConfig.getBaseURL()}${mpesaConfig.getEndpoints().oauth}`,
        {
          headers: {
            Authorization: `Basic ${auth}`,
          },
          timeout: 10000,
        }
      );

      const { access_token, expires_in } = response.data;

      if (!access_token) {
        throw new Error('No access token received from M-Pesa API');
      }

      // Cache the token (subtract 60 seconds for safety)
      const ttl = (expires_in - 60) * 1000;
      this.cacheService.set(this.CACHE_KEY, access_token, ttl);

      logger.info('Successfully retrieved new access token');
      return access_token;
    } catch (error) {
      const axiosError = error as AxiosError;
      logger.error('Failed to get access token:', {
        error: axiosError.message,
        status: axiosError.response?.status,
        data: axiosError.response?.data,
      });
      throw new Error(`Failed to get access token: ${axiosError.message}`);
    }
  }

  public generatePassword(): { password: string; timestamp: string } {
    const timestamp = Helpers.getTimestamp();
    const password = Buffer.from(
      `${mpesaConfig.businessShortCode}${mpesaConfig.passkey}${timestamp}`
    ).toString('base64');

    return { password, timestamp };
  }

  public async initiateStkPush(
    request: IStkPushRequest
  ): Promise<IStkPushResponse> {
    try {
      logger.info('Initiating STK Push', { phoneNumber: request.phoneNumber, amount: request.amount });

      // Validate request
      const validation = Helpers.validateStkPushRequest(request.phoneNumber, request.amount);
      if (!validation.isValid) {
        logger.warn('STK Push validation failed', { errors: validation.errors });
        return {
          success: false,
          message: 'Validation failed',
          errorCode: 'VALIDATION_ERROR',
        };
      }

      const accessToken = await this.getAccessToken();
      const { password, timestamp } = this.generatePassword();
      const formattedPhone = Helpers.formatPhoneNumber(request.phoneNumber);
      const transactionReference = Helpers.generateTransactionReference();

      const stkPushRequest = {
        BusinessShortCode: mpesaConfig.businessShortCode,
        Password: password,
        Timestamp: timestamp,
        TransactionType: mpesaConfig.transactionType,
        Amount: Math.round(request.amount),
        PartyA: formattedPhone,
        PartyB: mpesaConfig.businessShortCode,
        PhoneNumber: formattedPhone,
        CallBackURL: mpesaConfig.callbackURL,
        AccountReference: request.accountReference || transactionReference.substring(0, 12),
        TransactionDesc: request.transactionDesc || 'Payment for services',
      };

      logger.debug('STK Push request payload', { payload: stkPushRequest });

      const response = await axios.post(
        `${mpesaConfig.getBaseURL()}${mpesaConfig.getEndpoints().stkPush}`,
        stkPushRequest,
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
          },
          timeout: 30000,
        }
      );

      const responseData = response.data;

      if (responseData.ResponseCode === '0') {
        logger.info('STK Push initiated successfully', {
          checkoutRequestID: responseData.CheckoutRequestID,
          merchantRequestID: responseData.MerchantRequestID,
        });

        return {
          success: true,
          message: 'STK Push initiated successfully',
          data: {
            checkoutRequestID: responseData.CheckoutRequestID,
            merchantRequestID: responseData.MerchantRequestID,
            customerMessage: responseData.CustomerMessage,
            transactionReference,
          },
        };
      } else {
        logger.warn('STK Push initiation failed', {
          responseCode: responseData.ResponseCode,
          responseDescription: responseData.ResponseDescription,
        });

        return {
          success: false,
          message: responseData.ResponseDescription || 'Failed to initiate STK Push',
          errorCode: responseData.ResponseCode,
        };
      }
    } catch (error) {
      const axiosError = error as AxiosError;
      logger.error('STK Push initiation error', {
        error: axiosError.message,
        status: axiosError.response?.status,
        data: axiosError.response?.data,
        phoneNumber: request.phoneNumber,
      });

      return {
        success: false,
        message: axiosError.response?.data?.errorMessage || 'Internal server error',
        errorCode: 'INTERNAL_ERROR',
      };
    }
  }

  public async queryStkStatus(checkoutRequestID: string): Promise<any> {
    try {
      logger.info('Querying STK status', { checkoutRequestID });

      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.getBaseURL()}${mpesaConfig.getEndpoints().stkQuery}`,
        queryRequest,
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
          },
          timeout: 10000,
        }
      );

      return response.data;
    } catch (error) {
      const axiosError = error as AxiosError;
      logger.error('STK query error', {
        error: axiosError.message,
        checkoutRequestID,
      });
      throw error;
    }
  }

  public processCallback(callbackData: ICallbackData): {
    success: boolean;
    paymentDetails?: IPaymentDetails;
    error?: string;
  } {
    try {
      const stkCallback = callbackData.Body.stkCallback;
      const resultCode = stkCallback.ResultCode;
      const resultDesc = stkCallback.ResultDesc;

      logger.info('Processing M-Pesa callback', {
        resultCode,
        resultDesc,
        merchantRequestID: stkCallback.MerchantRequestID,
        checkoutRequestID: stkCallback.CheckoutRequestID,
      });

      if (resultCode === 0 && stkCallback.CallbackMetadata) {
        const items = stkCallback.CallbackMetadata.Item || [];
        const paymentDetails: IPaymentDetails = {};

        items.forEach((item) => {
          if (item.Name === 'Amount') paymentDetails.Amount = Number(item.Value);
          if (item.Name === 'MpesaReceiptNumber') paymentDetails.MpesaReceiptNumber = String(item.Value);
          if (item.Name === 'PhoneNumber') paymentDetails.PhoneNumber = String(item.Value);
          if (item.Name === 'TransactionDate') paymentDetails.TransactionDate = String(item.Value);
        });

        logger.info('Payment successful', paymentDetails);

        // Here you would:
        // 1. Update your database
        // 2. Trigger fulfillment processes
        // 3. Send notifications

        return {
          success: true,
          paymentDetails,
        };
      } else {
        logger.warn('Payment failed or cancelled', {
          resultCode,
          resultDesc,
          checkoutRequestID: stkCallback.CheckoutRequestID,
        });

        return {
          success: false,
          error: resultDesc,
        };
      }
    } catch (error) {
      logger.error('Callback processing error', {
        error: error instanceof Error ? error.message : 'Unknown error',
      });

      return {
        success: false,
        error: 'Failed to process callback',
      };
    }
  }

  public async simulateSandboxPayment(
    phoneNumber: string,
    amount: number
  ): Promise<{ success: boolean; message: string }> {
    if (!mpesaConfig.isSandbox()) {
      return {
        success: false,
        message: 'Simulation only available in sandbox mode',
      };
    }

    try {
      logger.info('Simulating sandbox payment', { phoneNumber, amount });

      // Simulate network delay
      await Helpers.delay(2000);

      // In a real simulation, you would trigger the actual STK Push
      // For this example, we'll simulate a successful response
      const simulationResponse = await this.initiateStkPush({
        phoneNumber,
        amount,
        transactionDesc: 'Sandbox Simulation',
      });

      return {
        success: simulationResponse.success,
        message: simulationResponse.message,
      };
    } catch (error) {
      logger.error('Sandbox simulation error', { error });
      return {
        success: false,
        message: 'Simulation failed',
      };
    }
  }
}

Step 10: Create Middleware

Create src/middlewares/validation.middleware.ts:

import { Request, Response, NextFunction } from 'express';
import { Helpers } from '../utils/helpers';
import { IApiResponse } from '../interfaces/response.interface';
import logger from '../utils/logger';

export class ValidationMiddleware {
  static validateStkPushRequest(req: Request, res: Response, next: NextFunction): void {
    const { phoneNumber, amount } = req.body;

    const validation = Helpers.validateStkPushRequest(phoneNumber, amount);

    if (!validation.isValid) {
      const response: IApiResponse = {
        success: false,
        message: 'Validation failed',
        error: validation.errors?.join(', '),
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      logger.warn('Request validation failed', {
        requestId: req.requestId,
        errors: validation.errors,
        phoneNumber,
        amount,
      });

      res.status(400).json(response);
      return;
    }

    next();
  }

  static validateCallbackRequest(req: Request, res: Response, next: NextFunction): void {
    const callbackData = req.body;

    if (!callbackData?.Body?.stkCallback) {
      const response: IApiResponse = {
        success: false,
        message: 'Invalid callback data structure',
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      logger.warn('Invalid callback structure', {
        requestId: req.requestId,
        body: callbackData,
      });

      res.status(400).json(response);
      return;
    }

    next();
  }

  static validateQueryStatusRequest(req: Request, res: Response, next: NextFunction): void {
    const { checkoutRequestID } = req.body;

    if (!checkoutRequestID || typeof checkoutRequestID !== 'string') {
      const response: IApiResponse = {
        success: false,
        message: 'Checkout Request ID is required and must be a string',
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      logger.warn('Invalid query status request', {
        requestId: req.requestId,
        checkoutRequestID,
      });

      res.status(400).json(response);
      return;
    }

    next();
  }
}

Create src/middlewares/logger.middleware.ts:

import { Request, Response, NextFunction } from 'express';
import { Security } from '../utils/security';
import logger from '../utils/logger';

export class LoggerMiddleware {
  static requestLogger(req: Request, res: Response, next: NextFunction): void {
    const requestId = Security.generateRandomString(8);
    req.requestId = requestId;

    const startTime = Date.now();

    // Log request
    logger.info('Incoming request', {
      requestId,
      method: req.method,
      url: req.url,
      ip: req.ip,
      userAgent: req.get('user-agent'),
    });

    // Log response
    res.on('finish', () => {
      const duration = Date.now() - startTime;
      logger.info('Request completed', {
        requestId,
        method: req.method,
        url: req.url,
        statusCode: res.statusCode,
        duration: `${duration}ms`,
      });
    });

    next();
  }

  static errorLogger(error: Error, req: Request, res: Response, next: NextFunction): void {
    logger.error('Unhandled error', {
      requestId: req.requestId,
      error: error.message,
      stack: error.stack,
      url: req.url,
      method: req.method,
    });

    next(error);
  }
}

Create src/middlewares/error.middleware.ts:

import { Request, Response, NextFunction } from 'express';
import { IApiResponse } from '../interfaces/response.interface';
import logger from '../utils/logger';

export class ErrorMiddleware {
  static handleError(
    error: Error,
    req: Request,
    res: Response,
    next: NextFunction
  ): void {
    const response: IApiResponse = {
      success: false,
      message: 'Internal server error',
      timestamp: new Date().toISOString(),
      requestId: req.requestId,
    };

    // Log error for debugging in development
    if (process.env.NODE_ENV === 'development') {
      response.error = error.message;
      response.stack = error.stack;
    }

    // Handle specific error types
    if (error.name === 'ValidationError') {
      res.status(400);
      response.message = error.message;
    } else if (error.name === 'UnauthorizedError') {
      res.status(401);
      response.message = 'Unauthorized';
    } else {
      res.status(500);
    }

    logger.error('Error handled by middleware', {
      requestId: req.requestId,
      error: error.message,
      statusCode: res.statusCode,
    });

    res.json(response);
  }

  static handleNotFound(req: Request, res: Response): void {
    const response: IApiResponse = {
      success: false,
      message: 'Endpoint not found',
      timestamp: new Date().toISOString(),
      requestId: req.requestId,
    };

    logger.warn('Endpoint not found', {
      requestId: req.requestId,
      url: req.url,
      method: req.method,
    });

    res.status(404).json(response);
  }
}

Step 11: Create Controllers

Create src/controllers/mpesa.controller.ts:

import { Request, Response } from 'express';
import { MpesaService } from '../services/mpesa.service';
import { IApiResponse } from '../interfaces/response.interface';
import { IStkPushRequest } from '../interfaces/mpesa.interface';
import logger from '../utils/logger';

export class MpesaController {
  private mpesaService: MpesaService;

  constructor() {
    this.mpesaService = new MpesaService();
  }

  public initiateStkPush = async (req: Request, res: Response): Promise<void> => {
    try {
      const request: IStkPushRequest = req.body;

      logger.info('Received STK Push request', {
        requestId: req.requestId,
        phoneNumber: request.phoneNumber,
        amount: request.amount,
      });

      const result = await this.mpesaService.initiateStkPush(request);

      const response: IApiResponse = {
        success: result.success,
        message: result.message,
        data: result.data,
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      const statusCode = result.success ? 200 : 400;
      res.status(statusCode).json(response);
    } catch (error) {
      logger.error('Controller error in initiateStkPush', {
        requestId: req.requestId,
        error: error instanceof Error ? error.message : 'Unknown error',
      });

      const response: IApiResponse = {
        success: false,
        message: 'Failed to process request',
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      res.status(500).json(response);
    }
  };

  public handleCallback = async (req: Request, res: Response): Promise<void> => {
    try {
      logger.info('Received M-Pesa callback', {
        requestId: req.requestId,
        body: req.body,
      });

      const result = this.mpesaService.processCallback(req.body);

      // Always return success to M-Pesa to acknowledge receipt
      // Even if processing failed, we don't want M-Pesa to retry
      const mpesaResponse = {
        ResultCode: 0,
        ResultDesc: 'Callback processed successfully',
      };

      res.status(200).json(mpesaResponse);

      // Log the processing result
      if (result.success) {
        logger.info('Callback processed successfully', {
          requestId: req.requestId,
          paymentDetails: result.paymentDetails,
        });
      } else {
        logger.warn('Callback processing failed', {
          requestId: req.requestId,
          error: result.error,
        });
      }
    } catch (error) {
      logger.error('Controller error in handleCallback', {
        requestId: req.requestId,
        error: error instanceof Error ? error.message : 'Unknown error',
      });

      // Still return success to M-Pesa to prevent retries
      const mpesaResponse = {
        ResultCode: 0,
        ResultDesc: 'Callback received',
      };

      res.status(200).json(mpesaResponse);
    }
  };

  public queryStkStatus = async (req: Request, res: Response): Promise<void> => {
    try {
      const { checkoutRequestID } = req.body;

      logger.info('Received STK status query', {
        requestId: req.requestId,
        checkoutRequestID,
      });

      const result = await this.mpesaService.queryStkStatus(checkoutRequestID);

      const response: IApiResponse = {
        success: true,
        message: 'Status query successful',
        data: result,
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      res.status(200).json(response);
    } catch (error) {
      logger.error('Controller error in queryStkStatus', {
        requestId: req.requestId,
        error: error instanceof Error ? error.message : 'Unknown error',
      });

      const response: IApiResponse = {
        success: false,
        message: 'Failed to query status',
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      res.status(500).json(response);
    }
  };

  public simulatePayment = async (req: Request, res: Response): Promise<void> => {
    try {
      const { phoneNumber, amount } = req.body;

      logger.info('Received sandbox simulation request', {
        requestId: req.requestId,
        phoneNumber,
        amount,
      });

      const result = await this.mpesaService.simulateSandboxPayment(phoneNumber, amount);

      const response: IApiResponse = {
        success: result.success,
        message: result.message,
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      const statusCode = result.success ? 200 : 400;
      res.status(statusCode).json(response);
    } catch (error) {
      logger.error('Controller error in simulatePayment', {
        requestId: req.requestId,
        error: error instanceof Error ? error.message : 'Unknown error',
      });

      const response: IApiResponse = {
        success: false,
        message: 'Simulation failed',
        timestamp: new Date().toISOString(),
        requestId: req.requestId,
      };

      res.status(500).json(response);
    }
  };

  public getHealth = (req: Request, res: Response): void => {
    const response: IApiResponse = {
      success: true,
      message: 'M-Pesa API is healthy',
      data: {
        service: 'M-Pesa Integration API',
        version: '1.0.0',
        timestamp: new Date().toISOString(),
        environment: process.env.NODE_ENV || 'development',
      },
      timestamp: new Date().toISOString(),
      requestId: req.requestId,
    };

    res.status(200).json(response);
  };
}

Step 12: Create Routes

Create src/routes/mpesa.routes.ts:

import { Router } from 'express';
import { MpesaController } from '../controllers/mpesa.controller';
import { ValidationMiddleware } from '../middlewares/validation.middleware';

const router = Router();
const mpesaController = new MpesaController();

// Health check
router.get('/health', mpesaController.getHealth);

// STK Push
router.post(
  '/stkpush',
  ValidationMiddleware.validateStkPushRequest,
  mpesaController.initiateStkPush
);

// Callback URL (called by M-Pesa)
router.post(
  '/callback',
  ValidationMiddleware.validateCallbackRequest,
  mpesaController.handleCallback
);

// Query STK Status
router.post(
  '/query-status',
  ValidationMiddleware.validateQueryStatusRequest,
  mpesaController.queryStkStatus
);

// Sandbox simulation (only in development/sandbox)
if (process.env.NODE_ENV !== 'production') {
  router.post('/simulate', mpesaController.simulatePayment);
}

export default router;

Step 13: Create Main Application

Create src/app.ts:

import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import mpesaRoutes from './routes/mpesa.routes';
import { LoggerMiddleware } from './middlewares/logger.middleware';
import { ErrorMiddleware } from './middlewares/error.middleware';
import logger from './utils/logger';

class App {
  public app: Application;

  constructor() {
    this.app = express();
    this.initializeMiddlewares();
    this.initializeRoutes();
    this.initializeErrorHandling();
  }

  private initializeMiddlewares(): void {
    // Security middleware
    this.app.use(helmet());
    this.app.use(cors());
    this.app.use(compression());

    // Body parsing
    this.app.use(express.json({ limit: '10mb' }));
    this.app.use(express.urlencoded({ extended: true, limit: '10mb' }));

    // Custom middleware
    this.app.use(LoggerMiddleware.requestLogger);
  }

  private initializeRoutes(): void {
    // API routes
    this.app.use('/api/mpesa', mpesaRoutes);

    // Handle 404
    this.app.use('*', ErrorMiddleware.handleNotFound);
  }

  private initializeErrorHandling(): void {
    this.app.use(LoggerMiddleware.errorLogger);
    this.app.use(ErrorMiddleware.handleError);
  }

  public start(port: number): void {
    this.app.listen(port, () => {
      logger.info(`Server is running on port ${port}`);
      logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
    });
  }
}

export default App;

Step 14: Create Entry Point

Create src/index.ts:

import dotenv from 'dotenv';
import App from './app';
import logger from './utils/logger';

// Load environment variables
dotenv.config();

const PORT = parseInt(process.env.PORT || '3000', 10);

// Validate required environment variables
const requiredEnvVars = [
  'MPESA_CONSUMER_KEY',
  'MPESA_CONSUMER_SECRET',
  'MPESA_BUSINESS_SHORTCODE',
  'MPESA_PASSKEY',
  'MPESA_CALLBACK_URL',
];

const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]);

if (missingVars.length > 0) {
  logger.error(`Missing required environment variables: ${missingVars.join(', ')}`);
  process.exit(1);
}

// Validate encryption key
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 32) {
  logger.error('ENCRYPTION_KEY must be exactly 32 characters long');
  process.exit(1);
}

// Start the application
const app = new App();
app.start(PORT);

// Handle graceful shutdown
process.on('SIGTERM', () => {
  logger.info('SIGTERM received. Shutting down gracefully...');
  process.exit(0);
});

process.on('SIGINT', () => {
  logger.info('SIGINT received. Shutting down gracefully...');
  process.exit(0);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection at:', { promise, reason });
});

process.on('uncaughtException', (error) => {
  logger.error('Uncaught Exception:', error);
  process.exit(1);
});

Step 15: Update package.json Scripts

Update your package.json:

{
  "name": "mpesa-typescript-integration",
  "version": "1.0.0",
  "description": "M-Pesa STK Push Integration with TypeScript",
  "main": "dist/index.js",
  "scripts": {
    "start": "node dist/index.js",
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "build:watch": "tsc --watch",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "lint": "eslint src/**/*.ts",
    "lint:fix": "eslint src/**/*.ts --fix",
    "format": "prettier --write src/**/*.ts",
    "type-check": "tsc --noEmit"
  },
  "keywords": [
    "mpesa",
    "stk-push",
    "typescript",
    "nodejs",
    "payment-integration"
  ],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "axios": "^1.6.0",
    "bcryptjs": "^2.4.3",
    "compression": "^1.7.4",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "helmet": "^7.0.0",
    "jsonwebtoken": "^9.0.2",
    "winston": "^3.11.0",
    "winston-daily-rotate-file": "^4.7.1"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.6",
    "@types/compression": "^1.7.5",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/helmet": "^4.0.0",
    "@types/jest": "^29.5.8",
    "@types/jsonwebtoken": "^9.0.5",
    "@types/node": "^20.10.0",
    "@types/supertest": "^6.0.2",
    "@typescript-eslint/eslint-plugin": "^6.14.0",
    "@typescript-eslint/parser": "^6.14.0",
    "eslint": "^8.55.0",
    "jest": "^29.7.0",
    "nodemon": "^3.0.1",
    "prettier": "^3.1.1",
    "supertest": "^6.3.3",
    "ts-jest": "^29.1.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.3.3"
  },
  "jest": {
    "preset": "ts-jest",
    "testEnvironment": "node",
    "testMatch": [
      "**/tests/**/*.test.ts"
    ],
    "collectCoverageFrom": [
      "src/**/*.ts",
      "!src/index.ts",
      "!src/**/*.interface.ts"
    ]
  }
}

Step 16: Testing the Integration

Start Development Server:

npm run dev

Test STK Push API:

Using curl:

curl --location 'http://localhost:3000/api/mpesa/stkpush' \
--header 'Content-Type: application/json' \
--data '{
    "phoneNumber": "0712345678",
    "amount": 100,
    "accountReference": "ORDER12345",
    "transactionDesc": "Payment for goods"
}'

Using Postman:

  1. POST to http://localhost:3000/api/mpesa/stkpush
  2. Headers: Content-Type: application/json
  3. Body:
{
    "phoneNumber": "0712345678",
    "amount": 100,
    "accountReference": "ORDER12345",
    "transactionDesc": "Payment for services"
}

Test Health Endpoint:

curl http://localhost:3000/api/mpesa/health

Step 17: Testing Callbacks Locally

For local development, use ngrok to expose your local server:

# Install ngrok
npm install -g ngrok

# Start ngrok tunnel
ngrok http 3000

# Update your .env file
MPESA_CALLBACK_URL=https://your-ngrok-url.ngrok.io/api/mpesa/callback

Step 18: Production Considerations

1. Environment Variables for Production:

NODE_ENV=production
PORT=8080

# M-Pesa Production Credentials
MPESA_CONSUMER_KEY=your_production_consumer_key
MPESA_CONSUMER_SECRET=your_production_consumer_secret
MPESA_BUSINESS_SHORTCODE=your_business_shortcode
MPESA_PASSKEY=your_production_passkey
MPESA_CALLBACK_URL=https://your-production-domain.com/api/mpesa/callback
MPESA_ENVIRONMENT=production

# Security
ENCRYPTION_KEY=your_32_char_production_encryption_key
JWT_SECRET=your_production_jwt_secret

2. Build for Production:

npm run build
npm start

3. PM2 for Process Management:

npm install -g pm2
pm2 start dist/index.js --name "mpesa-api"
pm2 save
pm2 startup

4. Nginx Configuration:

server {
    listen 80;
    server_name your-domain.com;

    location /api/mpesa/callback {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        # Your frontend configuration
    }
}

Key Features of This Implementation

  1. Type Safety: Full TypeScript support with strict type checking
  2. Security: Encryption, validation, and secure credential handling
  3. Error Handling: Comprehensive error handling at all levels
  4. Logging: Structured logging with rotation
  5. Caching: In-memory caching for access tokens
  6. Validation: Input validation middleware
  7. Scalable Structure: Modular architecture for easy extension
  8. Testing Ready: Jest configuration included
  9. Production Ready: Includes production optimizations

Common Issues and Solutions

  1. Invalid Phone Number Format: Use the format 07XXXXXXXX or 2547XXXXXXXX
  2. Callback Not Working: Ensure your callback URL is publicly accessible
  3. Token Expiry: Tokens expire every hour; cache handles automatic renewal
  4. Sandbox Testing: Use test credentials and test phone number 254708374149

Next Steps

  1. Add database integration (PostgreSQL/MongoDB)
  2. Implement webhook signature verification
  3. Add rate limiting
  4. Create admin dashboard
  5. Add more M-Pesa APIs (C2B, B2C, B2B)
  6. Implement queuing for callback processing

This TypeScript implementation provides a robust, scalable foundation for M-Pesa STK Push integration. The modular structure makes it easy to extend, and TypeScript ensures type safety throughout your application.

Posts Carousel

Leave a Comment

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

Latest Posts

Most Commented

Featured Videos