{
"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 running an SME in Kenya, you've probably asked yourself: *\"How do I actually track M-Pesa money without losing my mind in a spreadsheet?\"*\n\nSharkFlow's MpesaBooks solves exactly this problem—but the real story is *how* we built it to handle Kenya's chaotic, beautiful reality of mobile money. Let me walk you through the technical decisions that make it work.\n\n## The Problem We Started With\n\nKenya has 60M+ mobile money users, and M-Pesa processes $320B annually. Yet most SMEs still use:\n- SMS screenshots in WhatsApp groups\n- Manual Excel entries (good luck with reconciliation)\n- Bank statements they check once a month\n\nScaling accounting software for this reality means building for:\n- **Unreliable networks** (3G/EDGE in rural areas)\n- **Low-end devices** (budget Android phones with 1GB RAM)\n- **High transaction volume** (a kiosk owner doing 200+ transactions/day)\n- **Zero latency tolerance** (Kenyan traders need instant reconciliation)\n\n## API Design: Real-Time vs. Batch Sync\n\nWe started with a naive approach: poll Safaricom's M-Pesa API every 30 seconds. It failed immediately.\n\nHere's why: a single kiosk owner in Nairobi's informal sector does ~150 transactions/day. Polling every 30 seconds meant 2,880 API calls *per location per day*. Scale that to 10,000 SMEs and you're looking at 28.8M API calls daily. Safaricom's rate limits? 50 calls/minute. Dead on arrival.\n\n**Solution: Hybrid pull-push architecture.**\n\n```
javascript\n// Our transaction sync service\nconst mpesaTransactionSync = async (accountId) => {\n // 1. Check for webhook callbacks (push)\n const webhookTransactions = await db.webhooks\n .find({ accountId, processed: false })\n .lean();\n\n // 2. Only poll API for missing transactions (pull)\n const lastSyncTime = await getLastSyncTimestamp(accountId);\n const apiTransactions = await safaricomAPI.queryTransactions({\n accountId,\n startDate: lastSyncTime,\n limit: 100 // Small batch to respect rate limits\n });\n\n // 3. Deduplicate and merge\n const merged = deduplicateByTransactionId([\n ...webhookTransactions,\n ...apiTransactions\n ]);\n\n // 4. Reconcile with local state\n const reconciled = await reconcileWithLedger(merged);\n\n return reconciled;\n};\n
```\n\nWebhooks give us real-time updates; polling handles edge cases when callbacks fail (and they do, on African networks).\n\n## Database Architecture: The Ledger Problem\n\nAccounting software lives or dies by its ledger design. We use a **double-entry ledger model** but optimized for M-Pesa's unique properties:\n\n```
javascript\n// Schema: Transactions are immutable, ledger entries are append-only\ndb.createCollection('transactions', {\n validator: {\n $jsonSchema: {\n bsonType: 'object',\n required: ['transactionId', 'mpesaRef', 'amount', 'timestamp'],\n properties: {\n _id: { bsonType: 'objectId' },\n accountId: { bsonType: 'string' }, // SME identifier\n transactionId: { bsonType: 'string', unique: true }, // Safaricom's unique ID\n mpesaRef: { bsonType: 'string', index: true }, // User-visible receipt\n amount: { bsonType: 'decimal128' }, // Never use float for money\n type: { enum: ['receive', 'send', 'withdrawal'] },\n counterparty: { bsonType: 'string' }, // Who paid/received\n timestamp: { bsonType: 'date', index: true },\n status: { enum: ['pending', 'confirmed', 'reversed'] },\n ledgerEntries: { bsonType: 'array' }, // Links to accounting entries\n metadata: { bsonType: 'object' } // Tags, invoice refs, etc.\n }\n }\n }\n});\n\ndb.createCollection('ledger', {\n validator: {\n $jsonSchema: {\n bsonType: 'object',\n required: ['accountId', 'account', 'amount', 'timestamp'],\n properties: {\n _id: { bsonType: 'objectId' },\n accountId: { bsonType: 'string' },\n account: { enum: ['cash', 'revenue', 'expense', 'payable', 'receivable'] },\n amount: { bsonType: 'decimal128' },\n debit: { bsonType: 'boolean' },\n transactionRef: { bsonType: 'objectId' },\n timestamp: { bsonType: 'date', index: true },\n balanceSnapshot: { bsonType: 'decimal128' } // Cache for speed\n }\n }\n }\n});\n
\n\n*Why MongoDB?* We chose it because:\n1. Flexible schema for different transaction types (C2B, B2B, M-Pesa to bank)\n2. Horizontal scaling via sharding by accountId (critical for Kenya's distributed SMEs)\n3. TTL indexes for auto-archiving old data (storage is expensive in Africa)\n
Top comments (0)