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.
Do not write your own prefix table
It is tempting to map +2376… to MTN in your own code so you can show a logo early. Those prefixes
are the sandbox's synthetic ones, and they are wrong in production the moment a range is reassigned
or a customer ports their number. Send the number and read channel back from the payment.
Phone number formats has the whole rule.
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.
| Amount | What the customer experiences |
|---|---|
| Up to 500 000 XAF | One prompt, one PIN |
| Above 500 000 XAF | Several prompts in a row, payment held in partial between them |
| Above 2 000 000 XAF | 422 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 return | What the page should say |
|---|---|
succeeded | Confirmed, with what happens next |
pending, processing, partial | The prompt is out, confirming still works, we will email you |
failed, expired, cancelled | Nothing 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 happened | What the customer should be told |
|---|---|
| Not enough money in the wallet | Top up and try again, the order is still here |
| The prompt was declined | Nothing was charged, retry when ready |
| The prompt timed out | The operator did not get an answer in time, try once more |
| Wrong or unsupported number | Ask them to check the number, in full international form |
| The operator is down | Offer 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.
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.
| Suffix | Outcome | Webhook you receive |
|---|---|---|
000000 | The customer confirms | payment.succeeded |
000001 | Not enough money in the wallet | payment.failed |
000002 | The operator refuses | payment.failed |
000003 | No answer from the operator | payment.failed |
000004 | The customer declines the prompt | payment.failed |
000009 | Pays fine, but refunds always fail | payment.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.
Related pages
- Accept a paymentThe order lifecycle this flow hangs off.
- Hosted CheckoutEverything the payment page decides on its own.
- Phone number formatsE.164, carrier resolution, and what a bad number returns.
- Payment methods & channelsCountry by country operator coverage.
- Payment lifecycleEvery status, including `partial`, and what moves between them.
- Test scenariosEvery test number, card and outcome in sandbox.