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:
- POST to
http://localhost:3000/api/mpesa/stkpush - Headers:
Content-Type: application/json - 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
- Type Safety: Full TypeScript support with strict type checking
- Security: Encryption, validation, and secure credential handling
- Error Handling: Comprehensive error handling at all levels
- Logging: Structured logging with rotation
- Caching: In-memory caching for access tokens
- Validation: Input validation middleware
- Scalable Structure: Modular architecture for easy extension
- Testing Ready: Jest configuration included
- Production Ready: Includes production optimizations
Common Issues and Solutions
- Invalid Phone Number Format: Use the format
07XXXXXXXXor2547XXXXXXXX - Callback Not Working: Ensure your callback URL is publicly accessible
- Token Expiry: Tokens expire every hour; cache handles automatic renewal
- Sandbox Testing: Use test credentials and test phone number
254708374149
Next Steps
- Add database integration (PostgreSQL/MongoDB)
- Implement webhook signature verification
- Add rate limiting
- Create admin dashboard
- Add more M-Pesa APIs (C2B, B2C, B2B)
- 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.






















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