In Kenya, 90%+ of rent payments come through M-Pesa. If you're building any SaaS that handles recurring payments in East Africa, you'll hit the reconciliation problem fast.
Here's how I solved it for HomeManager — a property management platform where tenants pay rent via M-Pesa Paybill, and the system auto-matches each payment to the correct tenant and invoice.
The Problem
A 40-unit building receives ~40 M-Pesa transactions on rent day. Each transaction has:
- Phone number (MSISDN)
- Amount
- Account reference (what the tenant typed)
- Transaction ID
- Timestamp
The landlord needs to know: who paid, how much, and does it match what they owe?
Manually: 3 hours in Excel. With automation: 0 seconds.
Architecture Overview
Tenant pays via M-Pesa Paybill
↓
Safaricom Daraja API sends C2B callback
↓
Django webhook endpoint receives payment
↓
Matching engine identifies tenant
↓
Payment applied to invoice → balance updated
↓
SMS receipt sent to tenant via Africa's Talking
Step 1: Daraja API C2B Registration
First, register your confirmation and validation URLs with Safaricom:
# mpesa/services.py
import requests
from django.conf import settings
def register_c2b_urls():
url = f"{settings.MPESA_BASE_URL}/mpesa/c2b/v1/registerurl"
headers = {"Authorization": f"Bearer {get_access_token()}"}
payload = {
"ShortCode": settings.MPESA_SHORTCODE,
"ResponseType": "Completed",
"ConfirmationURL": f"{settings.BASE_URL}/api/mpesa/confirmation/",
"ValidationURL": f"{settings.BASE_URL}/api/mpesa/validation/",
}
return requests.post(url, json=payload, headers=headers)
Step 2: The Confirmation Webhook
This is where payments land:
# mpesa/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
class MpesaConfirmationView(APIView):
permission_classes = [] # Safaricom doesn't send auth headers
def post(self, request):
data = request.data
transaction = MpesaTransaction.objects.create(
transaction_id=data.get("TransID"),
amount=Decimal(data.get("TransAmount", "0")),
msisdn=data.get("MSISDN"),
account_reference=data.get("BillRefNumber", "").strip(),
transaction_time=parse_mpesa_timestamp(data.get("TransTime")),
raw_payload=data,
)
# Trigger async matching
match_payment_to_tenant.delay(transaction.id)
return Response({"ResultCode": 0, "ResultDesc": "Accepted"})
Step 3: The Matching Engine (The Hard Part)
Tenants type account references inconsistently:
- "A3" vs "a3" vs "A 3" vs "unit a3" vs "apt A3"
- Some type their name instead
- Some type nothing at all
The matching logic cascades through strategies:
# payments/matching.py
from celery import shared_task
@shared_task
def match_payment_to_tenant(transaction_id):
txn = MpesaTransaction.objects.get(id=transaction_id)
# Strategy 1: Exact account reference match
tenant = match_by_account_ref(txn.account_reference)
# Strategy 2: Fuzzy account reference
if not tenant:
tenant = match_by_fuzzy_ref(txn.account_reference)
# Strategy 3: Phone number match
if not tenant:
tenant = match_by_phone(txn.msisdn)
# Strategy 4: Amount + timing heuristic
if not tenant:
tenant = match_by_amount_timing(txn.amount, txn.transaction_time)
if tenant:
apply_payment(txn, tenant)
else:
flag_for_manual_review(txn)
def match_by_account_ref(ref):
normalized = ref.upper().strip().replace(" ", "")
for prefix in ["UNIT", "APT", "HOUSE", "HSE", "ROOM", "RM"]:
normalized = normalized.replace(prefix, "")
return Tenant.objects.filter(
unit__unit_number__iexact=normalized,
lease__is_active=True
).first()
def match_by_phone(msisdn):
phone = normalize_phone(msisdn)
return Tenant.objects.filter(
phone_number=phone,
lease__is_active=True
).first()
Step 4: Apply Payment to Invoice
def apply_payment(transaction, tenant):
invoice = Invoice.objects.filter(
tenant=tenant,
status__in=["unpaid", "partial"],
).order_by("due_date").first()
if not invoice:
create_credit(tenant, transaction.amount)
return
payment = Payment.objects.create(
tenant=tenant,
invoice=invoice,
amount=transaction.amount,
mpesa_transaction=transaction,
payment_method="mpesa",
)
invoice.amount_paid += transaction.amount
if invoice.amount_paid >= invoice.total_amount:
invoice.status = "paid"
else:
invoice.status = "partial"
invoice.save()
send_receipt_sms.delay(payment.id)
Real-World Edge Cases
After months in production:
- Duplicate callbacks — Safaricom sometimes sends the same transaction twice. Always check TransID uniqueness.
- Partial payments — Tenant owes KES 25,000, sends 10,000 then 15,000. Handle split payments against a single invoice.
- Wrong Paybill — Occasionally a payment meant for a different business lands on yours. Flag unmatched.
- Phone number changes — Tenant pays from spouse's phone. Phone-based matching fails.
- Zero account reference — ~15% of tenants leave it blank. Phone number becomes your only signal.
Match Rate Results
After tuning the cascade:
- 85% matched instantly (account reference)
- 10% matched by phone number
- 3% matched by amount/timing heuristic
- 2% flagged for manual review
98% auto-reconciliation. The landlord deals with 1 out of 50 payments manually instead of all 50.
The Stack
- Backend: Django + Django REST Framework
- Task queue: Celery + Redis
- M-Pesa: Safaricom Daraja API (C2B, STK Push, B2C)
- SMS: Africa's Talking
- Database: PostgreSQL
- Hosting: AWS (EC2, RDS, S3)
Try It
Daraja API docs: developer.safaricom.co.ke
See it in action for property management: homemanager.buniva.co.ke
Full comparison of property management software in Kenya: buniva.co.ke/blog/best-property-management-software-kenya.html
I'm Brian, founder of Buniva Technologies. Building property management software for East Africa. Connect on LinkedIn.
Top comments (0)