The Backbone of East African Payments
M-Pesa has evolved into a global financial standard, with Safaricom's Daraja API serving as the entry point for developers looking to build payment applications. Integrating M-Pesa involves managing asynchronous payment flows, secure token exchanges, and webhook processing. Because mobile money transactions occur in real-time, your system must handle edge cases like network timeouts, duplicate callbacks, and transaction status reconciliation.
This article walks through the architecture of a secure M-Pesa integration using TypeScript, demonstrating how to handle OAuth authentication, initiate an STK Push (Lipa Na M-Pesa Online), and process asynchronous callbacks securely.
System Architecture
M-Pesa transaction flows are asynchronous. Instead of a single HTTP request-response cycle, the integration involves:
1. Client Request: Initiates payment from your app. 2. API Handshake: Your backend requests an OAuth token and issues an STK Push to Safaricom. 3. User Input: The customer receives a secure PIN prompt on their mobile device and authorizes the payment. 4. Callback Processing: Safaricom issues an asynchronous POST request (webhook) to your backend containing the transaction details.
Here is the transaction workflow:
[User Mobile App] --- (Initiates checkout) ---> [Your Backend Server]
|
(1. Fetch OAuth Token)
(2. Send STK Push Request)
v
[User Mobile App] <--- (PIN Prompt) ---------- [Safaricom API Gate]
| |
(Enters PIN) |
v v
[Safaricom Core] ----- (Asynchronous Callback) -> [Your Webhook Endpoint]
|
(Reconcile Invoice DB)Implementing the Integration in TypeScript
Let's write a backend service using Node.js and TypeScript to manage the Daraja API handshake and callback processing.
import axios from 'axios';
import crypto from 'crypto';
interface MpesaConfig {
consumerKey: string;
consumerSecret: string;
shortCode: string;
passkey: string;
callbackUrl: string;
}
export class MpesaService {
private config: MpesaConfig;
constructor(config: MpesaConfig) {
self.config = config;
}
// Generate an OAuth access token
private async getAccessToken(): Promise<string> {
const auth = Buffer.from(
`\(self.config.consumerKey):\(self.config.consumerSecret)`
).toString('base64');
const response = await axios.get(
'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials',
{
headers: { Authorization: `Basic \(auth)` },
}
);
return response.data.access_token;
}
// Trigger STK Push (Lipa Na M-Pesa Online)
public async initiateStkPush(phone: string, amount: number, accountReference: string): Promise<any> {
const accessToken = await self.getAccessToken();
const timestamp = new Date().toISOString().replace(/[-T:Z.]/g, '').slice(0, 14);
// Password generation: Shortcode + Passkey + Timestamp
const password = Buffer.from(
`\(self.config.shortCode)\(self.config.passkey)\(timestamp)`
).toString('base64');
// Format phone to standard format (2547XXXXXXXX)
const formattedPhone = phone.startsWith('0') ? `254\(phone.slice(1))` : phone;
const payload = {
BusinessShortCode: self.config.shortCode,
Password: password,
Timestamp: timestamp,
TransactionType: 'CustomerPayBillOnline',
Amount: amount,
PartyA: formattedPhone,
PartyB: self.config.shortCode,
PhoneNumber: formattedPhone,
CallBackURL: self.config.callbackUrl,
AccountReference: accountReference,
TransactionDesc: `Payment for Order \(accountReference)`,
};
const response = await axios.post(
'https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/query',
payload,
{
headers: { Authorization: `Bearer \(accessToken)` },
}
);
return response.data;
}
}Secure Webhook Callback Handling
When Safaricom sends payment results to your callback URL, you must verify the payload and update your database. Here is an Express route showing how to process the webhook and reconcile transactions:
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
app.post('/api/mpesa/callback', async (req: Request, res: Response) => {
const { Body } = req.body;
if (!Body || !Body.stkCallback) {
return res.status(400).json({ error: 'Invalid payload' });
}
const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } = Body.stkCallback;
// Check if transaction was successful
if (ResultCode === 0 && CallbackMetadata) {
const items = CallbackMetadata.Item;
const amount = items.find((item: any) => item.Name === 'Amount')?.Value;
const mpesaReceiptNumber = items.find((item: any) => item.Name === 'MpesaReceiptNumber')?.Value;
const transactionDate = items.find((item: any) => item.Name === 'TransactionDate')?.Value;
const phoneNumber = items.find((item: any) => item.Name === 'PhoneNumber')?.Value;
console.log(`Payment Successful! Receipt: \(mpesaReceiptNumber), Amount: \(amount), Phone: \(phoneNumber)`);
// Perform database reconciliation here:
// await OrderStore.completePayment(CheckoutRequestID, mpesaReceiptNumber, amount);
} else {
console.error(`Payment Failed: \(ResultDesc) (Code: \(ResultCode))`);
// Handle payment failure case
}
// Always return success response to Safaricom to prevent callback retries
return res.status(200).json({ ResultCode: 0, ResultDesc: 'Success' });
});Reconciliation and Retry Strategies
1. Idempotency: Always check if a callback with the same CheckoutRequestID has already been processed to avoid duplicating database updates.
2. Polling Fallback: Sometimes callbacks fail due to network disruptions. Your system should run a cron job that queries Safaricom's status API for pending transactions.
3. Logging: Log all raw incoming payloads in an audit trail for manual verification and diagnostics.
