Build a Payments Port, Not a Daraja Wrapper: Provider-Agnostic Money Movement in Africa
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.SUCCEEDEDorFAILED- 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:
| Provider | Amount unit | KES 1,499.50 is sent as |
|---|---|---|
| Daraja STK | Whole shillings, integer | Rejected - must be 1499 or 1500 |
| Paystack | Minor units (cents) | 149950 |
| Flutterwave | Major units, decimal | 1499.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.