Payments in Africa are not a single problem. They are a dozen problems stitched together — each country has its own dominant rails, its own preferred currency, its own quirks, and its own set of providers that may or may not have reliable APIs. When I set out to build a merchant payment platform covering Nigeria, Ghana, Kenya, and Francophone West Africa, I thought the hardest part would be the code. I was wrong. The hardest part was understanding the terrain.
This is an account of what I built, how it works, and what I would do differently.
The Problem
The platform needed to let merchants collect payments from their customers across multiple African markets and then withdraw those funds to their local bank accounts or mobile money wallets. Simple on the surface. But "collect payments" means something different in Lagos versus Accra versus Nairobi versus Dakar.
In Nigeria, the dominant rails are bank transfers (via NIP/NIBSS) and mobile wallets like OPay. In Ghana, it is MTN Mobile Money. In Kenya, it is M-Pesa. In Senegal and the broader UEMOA zone, it is Orange Money, Wave, and Free Money. A unified interface over all of these required integrating multiple payment gateways and building an abstraction layer that could treat all of them consistently from the application's perspective.
The Architecture
The backend is a NestJS application backed by PostgreSQL via Prisma ORM. The frontend is a Next.js 15 app that serves both the public-facing fundraising pages and the merchant dashboard.
The database schema is managed with a startup migration script rather than Prisma migrations. This was a deliberate choice driven by a production constraint — the database is only reachable from the deployed server, not locally. Rather than fight that, every schema change is written as an idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS or CREATE TABLE IF NOT EXISTS statement that runs at boot time before the application starts. It is pragmatic, not elegant, but it has never caused a production incident.
Authentication is JWT-based. Merchants sign up, verify their email via OTP, and receive a token. All wallet and payout operations go through JWT-guarded endpoints. Merchants who use the API directly (for embedded checkout) authenticate via a separate key pair — an API merchant ID and an API key issued at signup.
Collections
Mobile Money
The mobile money collection flow follows a pattern I ended up replicating across all providers: initiate, then poll.
The merchant's customer submits a payment. The backend initiates a charge with the payment gateway, which pushes a payment prompt to the customer's phone. The frontend then polls a status endpoint every few seconds. When the gateway confirms the payment — or when the customer times out — the frontend responds accordingly.
The tricky part was status normalisation. Different gateways return status in different shapes. One returns { status: true } where true means success. Another returns { transStatus: "Successful" }. Another uses { status: "approved" }. I wrote a normalisation layer that maps all of these to a common pending | success | failed enum. Getting this wrong cost me actual money — a bug where a boolean true was overwriting a string status caused several successful payments to be recorded as pending. I had to write a recovery routine that re-queried the gateway at startup for all pending transactions and backfilled the ones that had actually succeeded.
Virtual Bank Accounts
For Nigerian merchants, I added support for virtual bank accounts — a dedicated NGN account number assigned to each merchant that customers can transfer to directly from any Nigerian bank. When a transfer lands, the payment gateway sends a webhook. The backend validates the webhook signature, finds the merchant by account number, credits their wallet, and writes a history entry.
This turned out to be one of the cleanest features in the whole system. Once the webhook handler is correct, the flow is entirely passive — no polling, no frontend involvement. Money arrives, the wallet is credited, done.
The webhook handler covers six event types in total: virtual account payins, mobile money collections, card payments, and their respective failure/refund states. Each event type is handled in its own branch with its own idempotency check — if the same event arrives twice, the second one is a no-op.
The Wallet
Every merchant has a wallet in the database. Balances are stored in the smallest currency unit — kobo for NGN, pesewa for GHS, cents for KES, centime for XOF — to avoid floating-point arithmetic errors. The API layer divides by 100 before returning balances to the frontend, and the frontend treats all received amounts as display-ready. No currency conversion happens in the UI layer.
This seems obvious but it is easy to get wrong, and I did get it wrong once — a /100 on the frontend was silently stacking on top of the /100 in the backend, making wallet balances appear 100× smaller than they were.
Payouts
This is where things got interesting.
The original payout system was manual — merchants submitted a withdrawal request, it sat in a pending state, and an admin processed it later. The merchants hated this. They wanted their money now.
The new system works in two phases. When a merchant submits a withdrawal:
Phase one happens synchronously, before the API responds. The wallet is debited immediately. This means the available balance reflects reality the moment the merchant submits — there is no window where the same funds could be withdrawn twice.
Phase two is the payout call to the gateway. For bank transfers, this involves a two-step process: first, validate the beneficiary account to confirm it exists and retrieve a session reference; then, initiate the disbursement using that reference. The session reference is the gateway's way of ensuring the disbursement is tied to a verified account — if you skip the validation step and the account number is wrong, the funds could be sent to the wrong person. For mobile money withdrawals, the validation step is not required — you provide the phone number and the provider code (e.g. orange, wave, mtn) and the disbursement goes straight through.
If the payout call fails for any reason — network error, invalid account, insufficient gateway balance — the wallet is refunded immediately in the same request. The merchant gets a clear error message and their balance is restored. The withdrawal record is marked failed. Nothing is lost.
If the payout succeeds, the withdrawal is marked processing. The funds are in transit.
On the admin side, if a withdrawal that was marked processing later fails at the bank level, the admin can mark it as failed. The system automatically refunds the wallet and writes a credit history entry. The merchant sees their balance restored with a note explaining what happened.
Multi-Currency
The platform supports NGN, GHS, KES, and XOF. Each currency has its own collection methods and its own withdrawal methods.
XOF merchants are a special case. West African mobile money systems do not map cleanly onto the bank-transfer-first assumption that the rest of the platform makes. XOF merchants only use mobile money — there are no Nigerian-style NIBSS bank transfers. So the withdrawal UI conditionally hides the bank tab for XOF wallets and shows only mobile money providers instead. The provider dropdown is filtered by the merchant's wallet currency, so a Senegalese merchant sees Wave, Orange, Free, and Expresso — not Nigerian options.
The mobile money provider list carries two codes per entry: the collection code (used when initiating a payment request from a customer) and the payout code (used when sending money out). These are not always the same. The gateway uses lowercase strings for payout provider codes (orange, mtn, wave) but the collection API uses different identifiers. Keeping these separate prevented a class of subtle bugs.
What I Would Do Differently
Webhook reliability first. I underestimated how much of the system's correctness depends on webhook handling. I should have built the idempotency layer, signature verification, and event logging before anything else — not as an afterthought once I noticed duplicate credits.
Treat money amounts as integers everywhere. I knew this rule going in and still violated it once. The lesson: write a currency utility module at the start of the project that enforces integer arithmetic and use it everywhere. Do not let the conversion happen in ad hoc places.
Database access from local. Working without direct database access is painful. Even if the production database cannot be public, a staging database with the same schema would have cut debugging time significantly.
Test payout flows with real credentials earlier. The IP whitelist error that blocked payouts in production was something I could have discovered on the first day of integration testing. Most payment gateways have IP whitelisting requirements that are only documented in passing. Check this before writing any payout code.
Closing
Building payments across multiple African markets taught me that the underlying problem is not technical — it is the fragmentation of financial infrastructure across 54 countries that have each built their own systems largely in isolation. The technical work is largely about bridging that fragmentation cleanly enough that a merchant does not have to think about it.
The best payment infrastructure is invisible. The merchant logs in, sees their balance, hits withdraw, and the money arrives. Everything else — the webhook handlers, the idempotency checks, the currency normalisation, the two-step payout validation — is scaffolding that should never be visible to the person using it.
That is the goal. Build scaffolding good enough that nobody ever has to look at it.