East Africa looks like one market from a pitch deck and four markets from a payments log.
The East African Community has spent two decades building a common market 3, and the GSMA's reports consistently show East Africa carrying the largest share of mobile money value on the continent 1. A company in Nairobi selling to Kampala, Kigali and Dar es Salaam is a natural expansion. But the payer in each of those cities uses a different set of wallets, a different currency with different decimal rules, and a different phone number format. One payment flow across all four is very achievable. It just has to be built on purpose.
Here is the order we would do it in.
Step 1: Write down the four markets as data, not as if-statements
Before any code, put the facts in one table your whole team can see.
| Kenya | Uganda | Rwanda | Tanzania | |
|---|---|---|---|---|
| Currency | KES | UGX | RWF | TZS |
| Decimal places in API amounts | 2 | 0 | 0 | 0 |
| Phone prefix | 254 | 256 | 250 | 255 |
| Total digits | 12 | 12 | 12 | 12 |
| Networks in checkout | M-Pesa (Safaricom), Airtel Money, T-Kash | MTN MoMo, Airtel Money | MTN MoMo, Airtel Money | M-Pesa (Vodacom), Airtel Money, Mixx by Yas, Halopesa, TTCL T-Pesa, AzamPesa |
| Wallet payouts | Coming | Coming | Coming | Live |
Two lines in that table cause most of the bugs. The decimal row, and the payouts row.
Ugandan shillings, Rwandan francs and Tanzanian shillings are sent to the API as whole numbers. The Kenyan shilling has two decimal places under ISO 4217 2, so KES amounts go in cents. If your code multiplies by 100 everywhere, Ugandan customers get a prompt for a hundred times the price. If it multiplies nowhere, Kenyan customers get a prompt for a hundredth. Read the decimal places from the currency, every time.
The country pages hold the current version of every row: Kenya, Uganda, Rwanda, Tanzania.
Step 2: Let the phone number choose the country
The customer already told you their country when they typed their number. Use it.
Normalise the number to digits only with the country code (no +, no spaces), then read the first three digits:
254means Kenya, charge in KES.256means Uganda, charge in UGX.250means Rwanda, charge in RWF.255means Tanzania, charge in TZS.
Ultraner's universal charge endpoint does the same thing on its side: it reads the prefix and routes to whichever rail serves that network in that country. In Tanzania several rails run side by side with automatic fallback; in Rwanda there is a primary and a fallback; Kenya and Uganda go through PawaPay. You pick the network. Ultraner picks the gateway, and your request body is identical across all four.
POST /v0/payments/charge
{ "method": "mobile_money", "amount": 35000, "phone": "256772123456", "provider": "MTN" }That is UGX 35,000 to an MTN MoMo customer in Uganda. Change the phone to 250788123456 and the amount to 5000 and it is RWF 5,000 in Kigali. Same code path.
Step 3: Show only the networks that work for that country
Once you know the country, the network picker should show that country's wallets and nothing else. A Rwandan customer offered T-Kash will either be confused or try it and fail.
Do not hard-code the list in your frontend. Call GET /v5/providers, which returns the networks actually live for your account, with their limits and current status, and filter by country. Then pre-select a network using GET /v5/providers/predict?msisdn=... and let the customer change it. The prediction is a guess: people keep their numbers when they switch networks, and charging the wrong network fails the payment.
Step 4: Price in the customer's currency
There are two honest ways to price across four currencies.
Local price lists. You set KES 1,500, UGX 40,000, RWF 15,000 and TZS 28,000 for the same plan, each a round number that looks natural to a local buyer. More work, better conversion.
One base price, converted at checkout. You price in one currency and let Ultraner's currency conversion show the payer their own currency, crediting you in yours. Less work, and the amounts will not be round.
Most teams start with the second and move their top products to the first once they see which markets buy. Whatever you do, do not show a Ugandan customer a price in Kenyan shillings and expect them to do the arithmetic.
Step 5: Treat pending as pending, in every market
The response to a mobile money charge is always pending. The customer is approving on their phone. Outcomes arrive on your webhook as payment.success or payment.failed.
The timing varies by network, and some networks never report a declined or ignored prompt. Ultraner closes those as failed after 10 minutes. Your webhook handler must accept a payment.success that arrives after a payment.failed for the same transaction_id, because occasionally the network confirms late. Build that once and it works for all four countries.
Step 6: Know what each market costs
Fees are not uniform across East Africa. The rail serving most markets outside Tanzania charges 1% of its own plus the network's charge, and Kenya, Uganda and Zambia price in tiers rather than a flat percentage. In Tanzania and Uganda the payer is also charged a fee by their own network on top of what you collect, which never touches Ultraner but is worth telling your customer about.
Before you set prices, pull real fee quotes for your common basket sizes in each country. A margin that works on a TZS 50,000 order may not work on a UGX 5,000 one.
Step 7: Decide where the four currencies end up
Four currencies coming in raises the question of where they go. You have two options, and you can mix them:
- Settle to your bank account. Settlement works to your own bank account in any country, with the FX spread shown separately when your account currency differs from your balance.
- Pay out to wallets. Live in Tanzania today, so a Tanzanian refund or commission can go straight to a phone. For the other three, settle and pay onward until those rails are live.
Most teams keep one operating balance and one bank account, and convert once at settlement rather than four times.
Step 8: Test each country in the sandbox
With a uk_test_ key, every rail has a sandbox counterpart and no real gateway is ever touched. Outcomes are keyed off the end of the phone number, so you can test each country with its own prefix:
254700000001succeeds in Kenya.256700000002fails with insufficient funds in Uganda.250700000003times out in Rwanda and fails after about 10 minutes.255700000001succeeds in Tanzania, so you can check the whole-number TZS path end to end.
Run each of those through your real checkout, read the screens your customer would see, and fix anything that reads as "something went wrong". The docs list every sandbox outcome.
What you end up with
One endpoint, one webhook handler, one ledger, and a small per-country table that tells your code which currency, which decimals and which networks apply. When you add a fifth market, you add a row, not a rewrite. That is the whole point of doing the first four properly.




