{
"title": "How to Track M-Pesa Transactions for Business: Building Accounting Systems for African SMEs",
"content": "# How to Track M-Pesa Transactions for Business: Building Accounting Systems for African SMEs\n\nKenya processes over $320 billion annually through M-Pesa. Yet most SMEs still track transactions in spreadsheets or worse—paper notebooks. This isn't laziness; it's because existing accounting software wasn't built for how African businesses actually operate.\n\nAt SharkFlow, we've built MpesaBooks to solve this. Let me walk you through the technical architecture—not the marketing version, but the actual engineering decisions we made to track M-Pesa transactions reliably for thousands of Kenyan businesses.\n\n## The Core Problem: M-Pesa Isn't a Traditional Bank\n\nTraditional accounting systems assume:\n- Regular internet connectivity\n- Real-time API access to transaction data\n- Consistent data formats\n- Reliable timestamps\n\nM-Pesa operates differently:\n- Transactions happen on feature phones with no internet\n- Data arrives via SMS notifications (unstructured text)\n- Edge cases: duplicate SMS, delayed messages, network failures\n- Timestamps depend on carrier network reliability\n\nBefore you can build accounting on top of M-Pesa, you need to solve *message reliability*.\n\n## Architecture Layer 1: Message Ingestion\n\nWe capture M-Pesa transactions through multiple channels:\n\n```
typescript\n// SMS webhook handler (most reliable for low-bandwidth)\napp.post('/webhooks/sms', async (req, res) => {\n const { from, body, timestamp, messageId } = req.body;\n \n // Deduplicate: M-Pesa sometimes sends duplicate SMS\n const isDuplicate = await db.transactions.findOne({\n smsMessageId: messageId,\n phoneNumber: from\n });\n \n if (isDuplicate) {\n return res.json({ status: 'duplicate', skipped: true });\n }\n \n // Parse M-Pesa SMS format (they're weirdly consistent)\n const parsed = parseM2Response(body);\n \n // Queue for processing, don't block webhook\n await messageQueue.enqueue({\n raw: body,\n parsed,\n phoneNumber: from,\n receivedAt: new Date(timestamp),\n smsMessageId: messageId,\n source: 'sms'\n });\n \n res.json({ status: 'queued' });\n});\n\n// M-Pesa API polling (for businesses with MPESA FOR BUSINESS account)\napp.post('/webhooks/mpesa-api', authMiddleware, async (req, res) => {\n const { transactionId, phoneNumber, amount, timestamp, type } = req.body;\n \n // API transactions are more reliable but less common for SMEs\n await messageQueue.enqueue({\n transactionId,\n phoneNumber,\n amount,\n receivedAt: new Date(timestamp),\n source: 'api',\n type // 'incoming', 'outgoing', 'reversal'\n });\n \n res.json({ status: 'accepted' });\n});\n
```\n\n## Architecture Layer 2: Smart Parsing\n\nM-Pesa SMS messages look like:\n```
\nJJ78PQ6ML Confirmed. You have received Ksh100.00 from John Doe on 15/1/25 at 2:45 PM. New M-Pesa balance is Ksh5,432.10\n
```\n\nNaive regex parsing breaks. We use a multi-stage parser:\n\n```
typescript\nfunction parseM2Response(message: string) {\n const patterns = {\n received: /You have received Ksh([\\d,]+\\.\\d{2}) from (.+?) on ([\\d/]+) at ([\\d:]+\\s(?:AM|PM))\\./,\n sent: /You have sent Ksh([\\d,]+\\.\\d{2}) to (.+?) on ([\\d/]+) at ([\\d:]+\\s(?:AM|PM))\\./,\n withdrawal: /You withdrew Ksh([\\d,]+\\.\\d{2}) from ([A-Z\\s]+) on ([\\d/]+) at ([\\d:]+\\s(?:AM|PM))\\./,\n reversal: /You have reversed Ksh([\\d,]+\\.\\d{2}) /\n };\n \n let match, type, amount, counterparty, dateStr, timeStr;\n \n // Try each pattern\n for (const [typeKey, pattern] of Object.entries(patterns)) {\n match = message.match(pattern);\n if (match) {\n type = typeKey;\n [, amount, counterparty, dateStr, timeStr] = match;\n break;\n }\n }\n \n if (!type) {\n return { success: false, reason: 'unknown_format' };\n }\n \n // Normalize currency strings (Ksh1,234.56 → 1234.56)\n const normalizedAmount = parseFloat(amount.replace(/,/g, ''));\n \n // Parse Kenyan date format (15/1/25 → full date with current year context)\n const [day, month, year] = dateStr.split('/');\n const fullYear = year.length === 2 ? `20${year}` : year;\n const txDate = new Date(`${fullYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${timeStr}`);\n \n return {\n success: true,\n type,\n amount: normalizedAmount,\n counterparty: counterparty.trim(),\n timestamp: tx
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)