Skip to content

Mobile Money checkout

What happens between the prompt and the confirmation, and how to build for it.

A card payment is decided by a machine in about two seconds. A Mobile Money payment is decided by a person holding a phone, who has to unlock it, read a prompt and type a PIN. Everything that makes this integration different comes from that one fact.

This guide is about the waiting, the failing and the retrying. For the API calls themselves, the payments quickstart is shorter and covers them properly.

What actually happens

Between your request and the money, four actors are involved and only the first is yours.

The three middle steps can take anywhere from ten seconds to several minutes, and you control none of them. Your interface has to be honest about that instead of hanging on a spinner.

The phone number chooses the operator

You do not tell Wajub which operator to charge. You send the customer's number in E.164 form and Wajub resolves the carrier from it, using libphonenumber's carrier database in production, which means portability and newly allocated ranges are handled for you.

curl https://api.wajub.com/payments \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Idempotency-Key: order-9041" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "currency": "XAF",
    "customer": {
      "name": "Amina Nkosi",
      "phone": "+237670000000",
      "email": "amina@example.com"
    },
    "description": "Order #9041",
    "reference": "order-9041",
    "callback": "https://shop.example.com/orders/9041/return"
  }'

The answer carries authorization_url. Redirect there and Wajub renders the operator choice, the prompt instruction and the result screen, in English or French depending on your regional setting.

Amounts have a ceiling, and it is lower than you think

Mobile Money caps a single debit at 500 000 XAF, and the same in XOF. The API itself accepts payments up to 2 000 000 XAF, so the two limits do not agree and the difference is handled for you: the hosted page collects a large payment in successive tranches rather than refusing it.

While that is happening the payment sits in partial, which is a real status your code will see.

AmountWhat the customer experiences
Up to 500 000 XAFOne prompt, one PIN
Above 500 000 XAFSeveral prompts in a row, payment held in partial between them
Above 2 000 000 XAF422 at creation, with the bound in errors.amount

Treat partial as still in flight. It is neither a success nor a failure, and only succeeded should fulfil an order.

Your page while they confirm

The customer is away from your site for as long as it takes them to find their phone. When they come back, the payment is very often still processing.

Do not present that as an error, and do not poll the API in a tight loop. Ask once when the page loads, render the state honestly, and let the webhook do the rest. The return route is written out in Accept a payment, and the three branches it needs are these.

Status on returnWhat the page should say
succeededConfirmed, with what happens next
pending, processing, partialThe prompt is out, confirming still works, we will email you
failed, expired, cancelledNothing was charged, with a retry button

The pending page is the one people get wrong. It should say what is actually true: the prompt has been sent, answering it on the handset still works, and the order will be updated on its own. Then send the confirmation by email when the webhook lands, so nobody has to sit on that page.

What fails, and what to say about it

A failure here is usually a person or a network, not a bug. The payment carries a failure_reason you can log, and the reason names something a human can act on.

What happenedWhat the customer should be told
Not enough money in the walletTop up and try again, the order is still here
The prompt was declinedNothing was charged, retry when ready
The prompt timed outThe operator did not get an answer in time, try once more
Wrong or unsupported numberAsk them to check the number, in full international form
The operator is downOffer a different operator rather than a retry on the same one

`failure_reason` is a diagnostic, not an enumeration

Its values come from the provider that handled the attempt, so new ones appear without warning. Branch your code on status. Use failure_reason to log, and to pick a human sentence from a map with a sensible default. Payment lifecycle covers this.

Retrying is a new payment

There is no way to re-prompt an existing payment. A failed or expired payment is finished; a retry means creating another one for the same order.

That has one consequence worth planning for. The new payment needs a new idempotency key, because reusing the old one returns the old failed payment instead of opening a fresh attempt.

curl https://api.wajub.com/payments \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Idempotency-Key: order-9041-attempt-2" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "currency": "XAF",
    "customer": { "phone": "+237670000000" },
    "reference": "order-9041",
    "callback": "https://shop.example.com/orders/9041/return"
  }'

Since the order now points at the newest payment, keep the previous ids somewhere if you want a full history. Nothing in Wajub links two attempts at the same order together.

Who pays the fee

By default the fee comes out of what you receive. Set bearer to customer and it is added on top instead, which the hosted page then shows explicitly: the amount, the fee and the total actually being debited.

Charge the fee to the customer
{
"amount": 15000,
"currency": "XAF",
"customer": {
"phone": "+237670000000"
},
"bearer": "customer"
}

The customer confirms the total on their handset, so this choice has to be made before they are prompted. It cannot be changed afterwards.

Test every branch without an operator

In sandbox the last six digits of the number decide the outcome. The prefix picks the country and operator, the suffix picks what happens, and any PIN is accepted.

SuffixOutcomeWebhook you receive
000000The customer confirmspayment.succeeded
000001Not enough money in the walletpayment.failed
000002The operator refusespayment.failed
000003No answer from the operatorpayment.failed
000004The customer declines the promptpayment.failed
000009Pays fine, but refunds always failpayment.succeeded

On Cameroon MTN, +23767 plus 000001 gives +237670000001 and reproduces an insufficient funds failure every time. Every country and operator prefix is listed in Test scenarios.

The 000009 number is the one to remember. It is the only way to exercise your refund failure handling, which is otherwise impossible to trigger on purpose.

Watch the routing while it happens

Open Konsole › Event Stream before you trigger a test payment. Provider selection, the operator response and the final outcome appear as they happen. After the fact, Routing Log shows the same thing for a payment that already finished.

What did you think of this content?