
To build an enterprise-grade scan-to-pay engine, you need a resilient architecture that guarantees idempotency, high availability, and sub-second merchant updates.
[Customer Phone]
│
(Scans QR Code)
│
▼
[M-Pesa / Bank App]
│
(Executes Payment)
│
▼
[Daraja Gateway]
│
├───────────────────────────────────────────────────┐
(Synchronous HTTP) (Async Callback)
│ │
▼ ▼
[Express.js Server] ◄───(Query Status)──── [Webhook Handler Endpoint]
│ │
(Generates QR) (Parses Payload)
│ │
▼ ▼
[Redis Cache] ◄──────────────(Publish Event)─────────── [PostgreSQL]
│ │
└───────────────────────┬───────────────────────────┘
│
(WebSocket)
│
▼
[Merchant Device / Speaker]Safaricom’s Daraja API provides a dedicated endpoint to generate dynamic QR codes.The dynamically generated payload embeds the Credit Party Identifier (Till/Paybill) and exact amount.
JavaScript
// services/mpesaService.jsconst axios = require('axios');const redis = require('../config/redis');class MpesaService {
constructor() {
this.consumerKey = process.env.DARJA_CONSUMER_KEY;
this.consumerSecret = process.env.DARJA_CONSUMER_SECRET;
this.authUrl = 'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials';
this.qrUrl = 'https://api.safaricom.co.ke/mpesa/qrcode/v1/generate';
}
/**
* Fetches or retrieves cached OAuth Access Token
*/ async getAccessToken() {
const cachedToken = await redis.get('daraja_access_token');
if (cachedToken) return cachedToken;
const auth = Buffer.from(`${this.consumerKey}:${this.consumerSecret}`).toString('base64');
try {
const response = await axios.get(this.authUrl, {
headers: { Authorization: `Basic ${auth}` }
});
const token = response.data.access_token;
// Cache token for 55 minutes (expires in 60 mins) await redis.set('daraja_access_token', token, 'EX', 3300);
return token;
} catch (error) {
console.error('OAuth Failed:', error.response?.data || error.message);
throw new Error('M-Pesa Authentication Failed');
}
}
/**
* Generates Dynamic QR Code Base64 Data
*/ async generateDynamicQR({ merchantName, refNo, amount, tillNumber }) {
const token = await this.getAccessToken();
const payload = {
MerchantName: merchantName,
RefNo: refNo,
Amount: Number(amount),
TrxCode: "BG", // BG = Buy Goods, PB = Paybill, WA = Withdraw CPI: tillNumber.toString(),
Size: "300" };
try {
const response = await axios.post(this.qrUrl, payload, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' }
});
if (response.data.ResponseCode === "00") {
return {
success: true,
qrCodeBase64: `data:image/png;base64,${response.data.QRCode}`,
refNo: refNo
};
} else {
throw new Error(response.data.ResponseDescription);
}
} catch (error) {
console.error('QR Generation Failed:', error.response?.data || error.message);
throw error;
}
}
}module.exports = new MpesaService();When a payment completes, Safaricom fires an asynchronous JSON webhook to your server. Because webhooks can be retried or duplicated due to network jitter, your backend must be strictly idempotent.
JavaScript
// routes/webhook.jsconst express = require('express');const router = express.Router();const db = require('../config/database');const redis = require('../config/redis');module.exports = (io) => {
router.post('/mpesa/c2b-callback', async (req, res) => {
// 1. Instantly respond to Safaricom to prevent timeouts (< 2 seconds) res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
try {
const payload = req.body;
// Extract Callback Metadata const stkData = payload.Body?.stkCallback || payload;
const resultCode = stkData.ResultCode;
if (resultCode !== 0) {
console.warn(`Transaction failed with code: ${resultCode}`);
return;
}
const metaItems = stkData.CallbackMetadata?.Item || [];
const mpesaReceipt = metaItems.find(i => i.Name === 'MpesaReceiptNumber')?.Value;
const amount = metaItems.find(i => i.Name === 'Amount')?.Value;
const phoneNumber = metaItems.find(i => i.Name === 'PhoneNumber')?.Value;
const transactionDate = metaItems.find(i => i.Name === 'TransactionDate')?.Value;
// 2. IDEMPOTENCY CHECK: Prevent processing duplicate receipt numbers const isDuplicate = await redis.get(`processed_tx:${mpesaReceipt}`);
if (isDuplicate) {
console.log(`Duplicate transaction ignored: ${mpesaReceipt}`);
return;
}
// Mark as processed in Redis for 24 hours await redis.set(`processed_tx:${mpesaReceipt}`, '1', 'EX', 86400);
// 3. Save to Database const query = `
INSERT INTO transactions (receipt_number, amount, phone_number, transaction_date, raw_payload)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, merchant_id;
`;
const dbRes = await db.query(query, [
mpesaReceipt,
amount,
phoneNumber,
transactionDate,
JSON.stringify(payload)
]);
const transaction = dbRes.rows[0];
// 4. REAL-TIME EVENT EMISSION // Broadcast to specific merchant room via WebSockets io.to(`merchant_${transaction.merchant_id}`).emit('payment_cleared', {
receipt: mpesaReceipt,
amount: amount,
phone: phoneNumber.toString().replace(/(\d{3})\d{4}(\d{3})/, '$1****$2'), // Mask phone timestamp: new Date()
});
console.log(`Payment successfully processed & broadcasted: ${mpesaReceipt}`);
} catch (err) {
console.error('Webhook processing error:', err.stack);
}
});
return router;
};