How to Build a Scan-to-Pay App in Kenya: Daraja API & Dynamic QR Guide

1. Complete Architecture Overview

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]

2. Step 1: Authentication & Dynamic QR Generation

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.  

Production Node.js Module for QR Generation

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();

3. Step 2: High-Throughput Callback Endpoint & Idempotency

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.

Production Webhook Endpoint with WebSocket Distribution

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;
};