~/mesha-vegas$⌘
$ cd ../

2026-07-17

Mobile Money: the real work starts when the payment fails

The happy path of a Mobile Money payment takes two days to code. The remaining three weeks go on timeouts, duplicates and reconciliation. Field notes.

  • #mobile-money
  • #mtn-momo
  • #orange-money
  • #fintech
  • #idempotence
  • #webhooks
$6 min read31 reads
Mobile Money: the real work starts when the payment fails

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:

  1. Get an access token
  2. Call requestToPay
  3. The user confirms on their phone
  4. Poll the status
  5. 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 Accepted and then never complete. The transaction stays PENDING forever.
  • The user can confirm after your timeout. They're debited; your system already gave up.
  • Step 4 can answer PENDING for forty seconds then SUCCESSFUL. Or PENDING for 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 INITIATED for 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 200 response — 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.