{
"title": "How to Track M-Pesa Transactions for Business: The Technical Architecture Behind MpesaBooks",
"content": "# How to Track M-Pesa Transactions for Business: The Technical Architecture Behind MpesaBooks\n\nIf you're a Kenyan developer building fintech tools, you've probably asked yourself: *How do we actually track M-Pesa transactions at scale without destroying our database?*\n\nM-Pesa processes over $1.2 billion in daily transactions across Kenya. For SMEs, this volume is both a blessing and a nightmare—they get paid instantly, but tracking those payments for accounting becomes a manual, error-prone hell.\n\nThis is the problem MpesaBooks solves. But the real story isn't about the UI. It's about how we designed the backend to handle real-time M-Pesa reconciliation on Africa's mobile networks.\n\nLet's talk architecture.\n\n## The Problem: Why Traditional Accounting Doesn't Work for M-Pesa\n\nM-Pesa's strength—instant settlement across Africa's largest telecom network—becomes a liability for SMEs trying to reconcile accounts.\n\nHere's what happens:\n\n1. **Real-time transactions arrive asynchronously** — Your customer sends payment at 2:14 PM. The SMS arrives at 2:14:03 PM. Your webhook fires at 2:14:07 PM. Meanwhile, your business logic is running somewhere else.\n\n2. **Network uncertainty** — Kenya's 4G coverage is ~85% in urban areas, but drops to 30% in rural zones. Webhooks fail. SMS gets delayed. Transactions get duplicated.\n\n3. **Manual reconciliation is death by a thousand cuts** — Most SMEs still download CSV files from M-Pesa, manually enter them into Excel, then into QuickBooks. When you process 50+ transactions daily, this becomes your full-time job.\n\n4. **Accounting complexity** — Is this revenue or a refund? Who is this customer? Did they already pay? Which invoice is this for?\n\nMost Kenyan businesses solve this with **spreadsheets and prayers**. We decided to solve it with **event-driven architecture and idempotent APIs**.\n\n## Architecture Layer 1: API Design for Reliability\n\nThe first principle: **every M-Pesa API call must be idempotent**.\n\nHere's our transaction ingestion endpoint:\n\n```
typescript\n// POST /api/v1/transactions/ingest\n// Purpose: Accept M-Pesa transaction data from multiple sources\n// Sources: Safaricom Daraja API, SMS parsing, M-Pesa B2B API\n\ninterface MpesaTransactionPayload {\n mpesaReceiptNumber: string; // Unique ID: e.g., \"NLJ7RF2QH3\"\n phoneNumber: string;\n amount: number;\n timestamp: ISO8601;\n senderName: string;\n transactionType: 'payment' | 'refund' | 'reversal';\n source: 'daraja' | 'sms_webhook' | 'ussd';\n}\n\n@Post('/ingest')\nasync ingestTransaction(@Body() payload: MpesaTransactionPayload) {\n const idempotencyKey = `${payload.mpesaReceiptNumber}-${payload.source}`;\n \n // Check if we've already processed this\n const existing = await db.transactions.findOne({ \n idempotencyKey \n });\n \n if (existing) {\n return { success: true, transaction: existing };\n }\n \n // Validate against M-Pesa's unique receipt number\n const isValid = await validateWithSafaricom(payload.mpesaReceiptNumber);\n if (!isValid) {\n throw new InvalidTransactionError();\n }\n \n // Atomically insert the transaction\n const transaction = await db.transactions.insertOne({\n ...payload,\n idempotencyKey,\n createdAt: new Date(),\n status: 'pending_reconciliation',\n });\n \n // Emit event for async processing\n await eventBus.emit('transaction.received', transaction);\n \n return { success: true, transaction };\n}\n
```\n\nWhy idempotency matters: If our webhook handler crashes after processing but before responding, Safaricom will retry. Without idempotent keys, we'd create duplicate transactions. With them, the second attempt returns the same result.\n\n## Architecture Layer 2: Database Design for African Networks\n\nWe don't use a single monolithic database. We use **event sourcing** with a **write-optimized store** and **read-optimized views**.\n\n```
javascript\n// Database Schema: PostgreSQL with Partitioning\n\nCREATE TABLE transactions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n idempotency_key VARCHAR(255) UNIQUE NOT NULL,\n mpesa_receipt_number VARCHAR(20) UNIQUE NOT NULL,\n phone_number VARCHAR(13) NOT NULL,\n amount DECIMAL(10, 2) NOT NULL,\n currency VARCHAR(3) DEFAULT 'KES',\n timestamp TIMESTAMP NOT NULL,\n sender_name VARCHAR(255),\n transaction_type VARCHAR(20) CHECK(transaction_type IN ('payment', 'refund', 'reversal')),\n source VARCHAR(20),\n status VARCHAR(50) DEFAULT 'pending_reconciliation',\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n INDEX idx_phone_timestamp (phone_number, timestamp),\n INDEX idx_mpesa_receipt (mpesa_receipt_number),\n INDEX idx_status (status)\n) PARTITION BY RANGE (YEAR(timestamp));\n\n// Denormalized View for Daily Reporting (updated hourly)\nCREATE MATERIALIZED VIEW daily_reconciliation AS\nSELECT
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)