← All engineering notes
PAYMENTS·6 min read

Payments for Africa: Why Mobile Money Isn't an Afterthought

Card rails are the default assumption in most payment integrations. Across the markets MakersMark serves, cards are the exception — and that changes the architecture, not just the checkout screen.

M-Pesa, MTN MoMo, and Airtel Money move more everyday transactions across the markets we build for than any card network. Treating mobile money as a bolt-on "alternative payment method" produces a checkout flow that fights its own users.

Card payments confirm in milliseconds; a mobile money payment often confirms via a webhook or USSD callback seconds to minutes later. MakersMark's payment core is built around that asynchrony from the start — pending states, idempotent reconciliation, and a ledger that doesn't assume "paid" means "confirmed right now."

payments/MobileMoneyWebhook.kt
sealed interface PaymentStatus
object Pending : PaymentStatus
object Confirmed : PaymentStatus
object Failed : PaymentStatus

fun onWebhook(event: MobileMoneyWebhook) {
    ledger.reconcile(
        reference = event.reference,
        status = if (event.success) Confirmed else Failed,
    ) // reconcile() is idempotent on 'reference'
}

We integrated M-Pesa, MTN MoMo, Airtel Money, Flutterwave, Paystack, and Monnify behind one internal payments interface, so the rest of the platform — payouts, refunds, subscriptions — never has to know which rail a given transaction moved through.

Model every payment as PENDING until an explicit confirmation event lands — a webhook, a poll, or a USSD callback — never assume synchronous success just because a rail usually confirms fast. Reconciliation should be idempotent, so a duplicated webhook can't double-credit a ledger.

Design the payments core around your slowest, least certain rail, not your fastest. Everything else gets easier to add once the ledger already assumes payments arrive out of order.