DEV Community

brian ambeyi
brian ambeyi

Posted on

How I Built M-Pesa Payment Auto-Reconciliation in Django (Daraja API)

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"})
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Real-World Edge Cases

After months in production:

  1. Duplicate callbacks — Safaricom sometimes sends the same transaction twice. Always check TransID uniqueness.
  2. Partial payments — Tenant owes KES 25,000, sends 10,000 then 15,000. Handle split payments against a single invoice.
  3. Wrong Paybill — Occasionally a payment meant for a different business lands on yours. Flag unmatched.
  4. Phone number changes — Tenant pays from spouse's phone. Phone-based matching fails.
  5. 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)