I build Laravel SaaS in Cotonou. Over the past couple of years I have integrated mobile money three times, once each for MTN, Wave and Orange Money, and every time I hit the same set of problems in the same order. This is that list, because I could not find it written down anywhere when I needed it.
The first one is the one that costs actual money.
The CFA franc has no centime
There is a rule most of us learned early and stopped questioning: never store money as a float, store it as an integer number of the smallest unit. Cents, pence, kobo. It is good advice and it is why Money libraries exist, and the good ones already know about the problem below: brick/money carries the whole ISO 4217 table and has XOF at an exponent of zero, and moneyphp/money models exponents too. If you reach for one of those you are fine. What follows is about what happens when people hand roll it instead, which in payment code they very often do, and about the provider APIs underneath, which is where I actually got burned.
ISO 4217 assigns every currency an exponent, which is how many decimal places it has. Most are 2. A handful are 3. A group of them are 0, meaning there is no smaller unit at all. XOF and XAF, the two CFA francs, are in that group. So is the Guinean franc, and so are the Japanese yen and Korean won that people are more likely to have heard about.
There is no centime of CFA franc in circulation. It is not rare or deprecated, it does not exist.
So when your money type assumes minor units are hundredths, and you charge a customer 10,000 XOF, this happens:
$amount = 10000; // ten thousand francs
$provider->charge($amount * 100); // one million francs
That is a hundredfold overcharge. It passes type checking and it passes code review, because the line looks exactly like the line you have written a hundred times for EUR. It surfaces when a customer calls you.
The fix is to make the currency carry its own exponent and to refuse anything that cannot be represented:
Money::of(10000, Currency::XOF)->forProvider(); // "10000"
Money::of(1500.50, Currency::XOF);
// InvalidMoneyException: XOF is a zero-decimal currency,
// so 1500.5 cannot be represented
Refusing matters more than it looks. Rounding picks a direction, and then someone is wrong by a franc on every transaction forever with nothing to tell you. A franc is not much. Across every transaction for two years it is a reconciliation problem.
Numbering plans move, and old numbers stop working
Every mobile money integration needs to know which network a phone number belongs to, because that decides which provider handles the payment. The obvious approach is a prefix table: numbers starting 97 are MTN, numbers starting 64 are Moov, and so on.
Prefix tables go stale. Benin moved to ten digit numbers in 2024, and every hardcoded eight digit rule written before that quietly stopped matching.
The failure mode is awkward because it does not raise an error. A stale table routes a payment to a provider that rejects it, and you get a failed transaction with a message about an invalid account, which sends you looking in the wrong place.
What I settled on: parse the number properly, and return null instead of guessing when the numbering plan for that market is not documented and stable. A parser that admits it does not know lets the calling code ask the customer, which beats failing silently.
A timeout is not a failure
This is the one that charges people twice.
You send a collection request. The connection times out. Nothing came back, so the natural thing is to treat it as failed and let the customer retry.
Except the request very likely arrived. Mobile money requests are asynchronous by nature: the provider sends a prompt to the customer's phone and the customer types a PIN, which might be thirty seconds later or five minutes later depending on whether they are on a bus or switching SIMs. Your HTTP timeout has nothing to do with whether the payment is happening. It tells you that you stopped listening.
So a retry after a timeout can create a second charge on the same order.
Two things prevent it. Generate an idempotency key when you create the request, keep it across retries, and require that calling collect twice with the same key cannot produce a second charge. Then query by that key first when you are unsure, instead of sending a fresh request.
$request = CollectionRequest::make(
amount: Money::of(1500, Currency::XOF),
payer: Msisdn::parse('+229 01 97 12 34 56'),
reference: 'ORDER-42',
);
// The key is generated here. Keep it. It is the only thing standing
// between a flaky connection and a double debit.
There is a second half to this that I got wrong in my own code, and only found by running against MTN's sandbox. Send a reference the provider has already seen and it answers 409 RESOURCE_ALREADY_EXIST, which is the idempotency key working. My driver treated every non-202 response as a rejection and threw, so a caller who timed out and retried with the same key got an exception, and the natural next move for them is to issue a fresh reference and pay twice. A conflict on a replayed key is a successful outcome and has to be reported as one.
The providers flatten distinctions that matter to you
MTN reports a customer declining the prompt and a customer never answering it as the same status: FAILED.
Those are different situations. Someone who declined has made a decision, and someone whose phone was in a bag for ten minutes has not. One is worth prompting again and the other is worth leaving alone, and a system that collapses both into "failed" will either pester the first group or abandon the second.
I separate them into Cancelled and Expired and let the application decide. The provider's status codes are an input to your domain model, not the model itself.
The same principle applies in reverse. Do not treat PENDING as an answer. The only state safe to fulfil an order on is a confirmed success, and everything else needs polling with a backoff and a give up window, because webhooks in this region are not reliable enough to be your only path to a final state.
The webhooks are not equally trustworthy
This one surprised me and I have not seen it written down.
Wave signs the webhook body with HMAC-SHA256 using a rotating secret. Standard, verifiable, fine.
Orange Money does not sign anything. When you create a payment it issues a notif_token, and when it calls you back it sends the same token in the body. Verification means comparing what arrived against what you stored.
That is a bearer secret travelling in a request body, and it is only as safe as the transport carrying it. It works, and you should know that is what you are relying on: serve the notification URL over HTTPS, treat the token as a credential and not an identifier, and do not log the request body somewhere your whole team can read it.
I mention this not to criticise Orange but because "we verify webhooks" is the kind of sentence that hides a real difference between two providers.
What I ended up building
After the third integration I made it a package: catidegla/laravel-mobile-money. One Laravel API across MTN MoMo, Wave and Orange Money, with all of the above baked in.
composer require catidegla/laravel-mobile-money
$transaction = MobileMoney::collect(CollectionRequest::make(
amount: Money::of(1500, Currency::XOF),
payer: Msisdn::parse('+229 01 97 12 34 56'),
reference: 'ORDER-42',
));
No driver named. The payer's number decides which network handles it.
Where it actually stands. The three drivers are implemented and covered by 105 tests that assert the exact request shape each provider documents, and CI is green on Laravel 12. Only MTN has met a live sandbox, and only partly: its Collections product is at Azure's subscription cap and closed to new developers, so the run had to go through the endpoints the APIs share. It was worth doing anyway, because it found the double charge bug described above. Wave and Orange need merchant credentials I do not have. Treat those two as documented and not proven, and run your own sandbox test before you go near production. The provider status table in the README says exactly which paths were exercised and which were not.
That is the ask. If you have merchant credentials for any of the three and ten minutes to run a collection against sandbox, I would like to hear what breaks.
One reason this might be timely
BCEAO extended the deadline for banks, e-money issuers and payment institutions to connect to the PI-SPI instant payment rail to 30 September 2026, with microfinance institutions having until June 2027. As of late June there were 80 participants connected and 74 more in live testing.
So a lot of people across the UEMOA zone are writing payment integration code right now. If you are one of them, I hope the list above saves you at least the first mistake.
Top comments (0)