A goalkeeper can have ninety perfect minutes. Nobody will mention it. They misjudge one cross in the 89th, and that's the match report.
Payment integration is exactly that position. When everything goes well, nobody notices your work. When a transaction goes missing, you have a customer who was debited and received nothing — and then everyone is watching.
I've integrated Mobile Money on several products — Transfergratis, My Collector, Driivio — and the finding is always the same: the happy path is 10% of the work.
The tutorial's lie
Open any MTN MoMo integration guide. You'll find roughly this:
- Get an access token
- Call
requestToPay - The user confirms on their phone
- Poll the status
- It's paid
That's accurate. It's also about as useful as a recipe that says "cook until done."
What the tutorial doesn't say:
- Step 2 can answer
202 Acceptedand then never complete. The transaction staysPENDINGforever. - The user can confirm after your timeout. They're debited; your system already gave up.
- Step 4 can answer
PENDINGfor forty seconds thenSUCCESSFUL. OrPENDINGfor forty seconds then nothing at all. - The operator network can be under maintenance, and you only learn it from complaints.
- The same user can launch three payments by tapping your button three times because nothing was visibly happening.
Rule number one: the client never initiates anything
I repeat this on every project because I see it violated in every audit: your mobile app never talks directly to the operator.
The reasons are obvious once stated:
- Your API credentials would end up in an APK that decompiles in ten minutes.
- A malicious client can lie about the amount.
- You have no server-side trace of what was attempted.
The correct flow:
App → your backend: "I want to pay order 4821"
your backend → operator: requestToPay(amount verified server-side)
operator → your backend: result webhook
your backend → app: notification / state update
The amount comes from your database, never from the client. It sounds basic. I've audited production apps where the amount travelled in the request body sent by the app.
Idempotency: the concept that saves nights
If you remember one thing: every payment attempt must carry an idempotency key generated before the call.
Concretely, before even contacting the operator, you write to your database:
payment_id : uuid you generated
order_id : 4821
amount : 15000
status : INITIATED
attempt : 1
That identifier accompanies the call to the operator and serves as the external reference. Then:
- If the user retries and the payment has been
INITIATEDfor less than N minutes, you do not relaunch: you query the existing one. - If the webhook arrives twice, you process it once.
- If your server restarts mid-flight, you recover in-flight payments at startup.
Without this, the classic scenario: the user taps three times, you launch three requestToPay, they confirm three times, they're debited three times. You'll spend your weekend refunding by hand.
Webhook and polling: you need both
Each mechanism has its own flaw, and they're complementary.
The webhook is fast but unreliable: your server may be down at that precise moment, the network may eat the notification, the operator may not retry.
Polling is reliable but expensive: querying every two seconds for five minutes times a thousand transactions is a denial of service you inflict on yourself.
What works:
- Webhook as the main route. Processed immediately, idempotent, fast
200response — you acknowledge receipt then process asynchronously, never the reverse. - Polling as a net, with an increasing interval: 2s, 5s, 10s, 30s, then active abandonment after 5 minutes with a move to
NEEDS_REVIEW. - A daily reconciliation job that picks up every unresolved payment and queries the operator. It's the one that catches what the other two let slip.
That reconciliation job is the piece almost nobody codes at the start, and it's the one that saves you. It turns a customer incident into a log line.
States, and why three aren't enough
The naive model: PENDING, SUCCESS, FAILED.
Not enough. You need at minimum:
| State | Meaning | Action |
|---|---|---|
INITIATED |
Written on our side, not yet sent | Recover at restart |
SENT |
Accepted by the operator | Await webhook / poll |
SUCCESS |
Confirmed | Deliver the service |
FAILED |
Explicitly refused | Inform, offer retry |
EXPIRED |
No answer within the window | Reconciliation |
NEEDS_REVIEW |
Reconciliation inconclusive | Human intervention |
That last state is the most important, and it's the one people refuse to add because it admits the system isn't perfect. It isn't. One payment in a thousand will end up there. Without that state, that payment silently pollutes your statistics and frustrates a real customer.
What you must show the user
Technique isn't enough. A Mobile Money payment goes through the user's phone — they have to type their PIN on a screen you don't control.
What makes the difference:
- Say what's about to happen, beforehand: "You'll receive a confirmation request on 6XX XXX XXX. Enter your PIN."
- A visible countdown. Waiting without a reference point feels twice as long.
- Never show "Failed" for a timeout. "We haven't received confirmation yet. If you were debited, your order will be validated automatically within a few minutes." It's true, and it prevents a panic second payment.
- A viewable history. A worried user's first reflex is to check. If they can't, they call. Or they pay again.
The underlying problem, for the profession
There's an asymmetry I find significant: documentation, libraries, off-the-shelf integrations, blog posts — the vast majority of the ecosystem is written for Stripe and for a context where the bank card dominates.
Yet in Central and West Africa, Mobile Money is the majority payment method. Tens of millions of people pay that way every day. And still, the developer who has to integrate it is left with uneven operator documentation, no SDK worth the name, no realistic test environment, and zero serious content in French.
That's one of the reasons I write these articles. The knowledge exists; it's just locked in the heads of those who learned it the hard way.
Pre-production checklist
- The backend initiates, never the client
- Idempotency key generated before the call
- Six states, including
NEEDS_REVIEW - Idempotent webhook, acknowledged before processing
- Polling with increasing interval and active abandonment
- Daily reconciliation job
- Audit log of every state transition
- User messages that distinguish failure from uncertainty
- Sandbox tests, including failure cases
- A written procedure for handling
NEEDS_REVIEW
That last point isn't technical, and that's exactly why it's almost always missing. Someone has to know what to do when a payment lands there, and that someone shouldn't be you at 11pm.
For the integration mechanics themselves, I wrote a full guide to the MTN MoMo and Orange Money flow. If you're building a product that has to collect payments in Africa, let's talk.
