</>CodeWithKarani

Build a Payments Port, Not a Daraja Wrapper: Provider-Agnostic Money Movement in Africa

Karani GeoffreyKarani Geoffrey7 min read

Every team that integrates M-Pesa directly eventually needs a second provider. Cards for the diaspora customers. Airtel Money for the 15 percent who are not on Safaricom. A payout rail that works on weekends. And then they discover that "M-Pesa" is spread across 40 files, that CheckoutRequestID is a column on the orders table, and that the refund flow imports a Daraja client directly.

So they build an abstraction. And usually they build the wrong one.

The wrong abstraction is gateway.charge(amount, customer). It collapses providers to their lowest common denominator, throws away everything that makes each rail useful, and leaks anyway the first time you need an STK-specific field. The right abstraction is a normalised state machine plus a normalised event. The provider becomes a translator, not a facade.

Model intent, not method calls

The stable thing across every African payment rail is not the HTTP call. It is the lifecycle:

  • CREATED - you have an intent, nothing has left your building.
  • SUBMITTED - the provider acknowledged the request.
  • PENDING_USER_ACTION - an STK prompt is on a handset, or a 3-D Secure page is open.
  • SUCCEEDED or FAILED - terminal.
  • INDETERMINATE - the provider timed out and nobody knows yet.

Note INDETERMINATE. Stripe-shaped abstractions do not have it. In East Africa you need it: an M-Pesa 1037, an Airtel Money timeout, a bank transfer that has left but not landed. If your state enum has no room for "we genuinely do not know yet", your code will lie.

The port

from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, Any, Literal
from decimal import Decimal

Status = Literal["SUBMITTED", "PENDING_USER_ACTION", "SUCCEEDED", "FAILED", "INDETERMINATE"]

@dataclass(frozen=True)
class Money:
    minor: int          # always integer minor units
    currency: str       # ISO 4217

@dataclass(frozen=True)
class CollectionRequest:
    intent_id: str              # YOUR id. doubles as idempotency key.
    amount: Money
    payer: str                  # E.164, e.g. +254712345678
    reference: str              # what the customer sees on their statement
    description: str
    metadata: dict[str, Any] = field(default_factory=dict)

@dataclass(frozen=True)
class ProviderAck:
    provider: str
    provider_ref: str | None    # CheckoutRequestID, flw tx_ref, paystack reference
    status: Status
    user_action: dict | None    # e.g. {"type": "stk_prompt"} or {"type": "redirect", "url": ...}
    raw: dict

@dataclass(frozen=True)
class NormalisedEvent:
    provider: str
    provider_ref: str           # the settlement reference. dedupe key.
    intent_id: str | None
    status: Status
    amount: Money | None
    occurred_at: datetime
    failure_code: str | None
    failure_reason: str | None
    raw: dict

class CollectionProvider(Protocol):
    name: str
    def supports(self, req: CollectionRequest) -> bool: ...
    def initiate(self, req: CollectionRequest) -> ProviderAck: ...
    def verify(self, provider_ref: str) -> NormalisedEvent: ...
    def parse_webhook(self, headers: dict, body: bytes) -> NormalisedEvent: ...

Four methods. That is the whole contract. verify is not optional and it is not a convenience - it is the escape hatch that makes the whole system correct when webhooks fail, and any provider that cannot offer it should not be in your stack.

A Daraja adapter

class DarajaSTK:
    name = "daraja_stk"

    def supports(self, req):
        return req.amount.currency == "KES" and req.payer.startswith("+2547")

    def initiate(self, req):
        # Daraja rejects decimals on Amount.
        if req.amount.minor % 100 != 0:
            raise ValueError("M-Pesa STK requires whole shillings")

        res = self.client.stk_push(
            amount=req.amount.minor // 100,
            msisdn=req.payer.lstrip("+"),
            account_ref=req.reference[:12],
            desc=req.description[:13],
            callback_url=self.callback_url,
        )
        if res.get("ResponseCode") != "0":
            return ProviderAck(self.name, None, "FAILED", None, res)

        return ProviderAck(
            provider=self.name,
            provider_ref=res["CheckoutRequestID"],
            status="PENDING_USER_ACTION",
            user_action={"type": "stk_prompt", "message": res.get("CustomerMessage")},
            raw=res,
        )

    def parse_webhook(self, headers, body):
        cb = json.loads(body)["Body"]["stkCallback"]
        items = {i["Name"]: i.get("Value")
                 for i in cb.get("CallbackMetadata", {}).get("Item", [])}
        code = int(cb["ResultCode"])

        status = ("SUCCEEDED" if code == 0
                  else "INDETERMINATE" if code in (1037, 1001)
                  else "FAILED")

        receipt = items.get("MpesaReceiptNumber") or cb["CheckoutRequestID"]
        amount = (Money(int(Decimal(str(items["Amount"])) * 100), "KES")
                  if "Amount" in items else None)

        return NormalisedEvent(
            provider=self.name,
            provider_ref=receipt,
            intent_id=None,                       # resolved via CheckoutRequestID lookup
            status=status,
            amount=amount,
            occurred_at=parse_daraja_ts(items.get("TransactionDate")) or now(),
            failure_code=str(code) if code else None,
            failure_reason=cb.get("ResultDesc"),
            raw=json.loads(body),
        )

Every provider-specific ugliness lives in that class. The integer-shillings rule, the 12-character reference truncation, the 1037 nuance, the timezone. Nothing above the port knows any of it.

Webhook authenticity is per-provider, so put it in the adapter

This is where a naive facade falls apart, because each provider authenticates differently and one of them does not authenticate at all.

import hmac, hashlib

class PaystackAdapter:
    name = "paystack"

    def parse_webhook(self, headers, body: bytes):
        sig = headers.get("x-paystack-signature", "")
        expected = hmac.new(self.secret_key.encode(), body, hashlib.sha512).hexdigest()
        if not hmac.compare_digest(sig, expected):
            raise WebhookAuthError("bad paystack signature")
        ...

class FlutterwaveAdapter:
    name = "flutterwave"

    def parse_webhook(self, headers, body: bytes):
        if not hmac.compare_digest(headers.get("verif-hash", ""), self.secret_hash):
            raise WebhookAuthError("bad flutterwave verif-hash")
        ...

class DarajaSTK:
    # No signature exists. Compensate with network controls plus mandatory re-verification.
    requires_verification = True

Make requires_verification a first-class flag on the port. Your pipeline reads it and, for those providers, refuses to move an intent to SUCCEEDED on the webhook alone - it calls verify() first. That single flag encodes the entire trust difference between Daraja and Paystack, in one place, testably.

Minor units: the bug that will bite you

This is the most common cross-provider defect I see in Kenyan codebases:

ProviderAmount unitKES 1,499.50 is sent as
Daraja STKWhole shillings, integerRejected - must be 1499 or 1500
PaystackMinor units (cents)149950
FlutterwaveMajor units, decimal1499.50

Store amount_minor as a BIGINT everywhere internally and convert only inside the adapter. Never let a float touch a money value, and never let a provider's convention leak upward. The Money dataclass above exists purely to make that impossible to forget.

Routing: the part that justifies the whole exercise

Once every provider implements the same port, provider choice becomes data instead of code.

class Router:
    def __init__(self, providers: list[CollectionProvider], rules: list[dict]):
        self.by_name = {p.name: p for p in providers}
        self.rules = rules

    def candidates(self, req: CollectionRequest) -> list[CollectionProvider]:
        for rule in self.rules:
            if rule["currency"] != req.amount.currency:
                continue
            if not req.payer.startswith(rule["msisdn_prefix"]):
                continue
            if not (rule["min_minor"] <= req.amount.minor <= rule["max_minor"]):
                continue
            chain = [self.by_name[n] for n in rule["providers"]]
            return [p for p in chain if p.supports(req) and self.healthy(p.name)]
        return []
rules:
  - currency: KES
    msisdn_prefix: "+2547"
    min_minor: 100
    max_minor: 15000000
    providers: [daraja_stk, flutterwave]   # direct first, aggregator as failover
  - currency: KES
    msisdn_prefix: "+2541"                 # Airtel Kenya range
    min_minor: 100
    max_minor: 15000000
    providers: [flutterwave]
  - currency: UGX
    msisdn_prefix: "+256"
    min_minor: 500
    max_minor: 20000000
    providers: [flutterwave]

Now "Safaricom is having a bad afternoon, route KES through the aggregator" is a config change with a circuit breaker, not a hotfix deploy. That capability alone has paid for the abstraction on every project where I have built it.

Test it with a contract suite, not mocks

Write one test suite that every adapter must pass, and a FakeProvider that lets the rest of your application be tested with zero network calls.

import pytest

@pytest.mark.parametrize("adapter", ALL_ADAPTERS)
def test_webhook_is_idempotent_and_normalised(adapter, fixtures):
    for fixture in fixtures.for_provider(adapter.name):
        ev = adapter.parse_webhook(fixture.headers, fixture.body)
        assert ev.provider_ref, "every event must carry a dedupe key"
        assert ev.status in VALID_STATUSES
        assert ev.amount is None or ev.amount.minor > 0
        # parsing twice must be pure
        assert adapter.parse_webhook(fixture.headers, fixture.body) == ev

@pytest.mark.parametrize("adapter", [a for a in ALL_ADAPTERS if a.signs_webhooks])
def test_rejects_tampered_body(adapter, fixtures):
    f = fixtures.for_provider(adapter.name)[0]
    with pytest.raises(WebhookAuthError):
        adapter.parse_webhook(f.headers, f.body + b" ")

Capture the fixtures from real traffic. Redact the MSISDNs, commit the JSON, and you have a regression suite that survives every provider's undocumented payload change.

Where to draw the line

Do not abstract things that are genuinely different. Card 3-D Secure redirects, M-Pesa STK prompts and bank-transfer instructions are not the same experience, so user_action is a tagged union your UI switches on, not a lie about them being identical. Do not build a universal refund API when M-Pesa reversals, card refunds and B2C payouts have different rules, latencies and failure modes - give payouts their own port.

A good payments abstraction is not one that hides the providers. It is one that makes their differences explicit, small, and located in exactly one file each.

Build the port, keep the adapters honest with a contract suite, put routing in config, and adding a fourth provider becomes an afternoon rather than a quarter.

#payments#architecture#m-pesa#flutterwave#paystack
Keep reading

Related articles