Mobile Money

Accept mobile money payments across Africa with a single integration

Overview

Mobile money is the dominant consumer payment method across most of Africa. Customers hold a wallet balance with a mobile network operator (MTN, M-Pesa, Airtel, Orange, and others) and authorise payments from their handset using a PIN. CrissCross gives you a single integration that covers every supported operator in every supported country.

This page is the umbrella reference for the payment method: how a payment works, what you collect, sandbox testing, and the error reference. The available operators differ by country — see the per-country pages for the operator list and any country-specific notes. For the end-to-end Direct API request/response sequence, see the Direct API: Mobile Money guide.


How a mobile money payment works

Unlike a card payment, a mobile money payment is asynchronous: the customer approves the debit outside your checkout. Most operators use a handset prompt flow:

  1. The merchant initiates a payment with the customer’s provider and phone number.
  2. The operator sends a push or USSD prompt to the customer’s handset.
  3. The customer enters their PIN to approve (or rejects, or lets it time out).
  4. The operator confirms the outcome back to CrissCross, which notifies you via webhook and exposes the final state on the transaction.

While the customer is approving, the transaction sits in AUTH_REQUIRED (not PENDING), and authState.authMethodType tells you what is happening:

  • pendingApproval — the prompt has been pushed to the customer’s handset. Show a “check your phone” screen; there is no POST /v1/payment/authorize step — approval happens entirely on the handset, and you either poll or wait for the webhook.
  • ussd — the customer must dial themselves: render authState.ussd.instruction verbatim in your checkout.
  • fields — the rail asks for a one-time PIN inside your checkout: the authState lists a single otp field, submitted via POST /v1/payment/authorize.
  • redirect — the customer authenticates on the operator’s own page: send them to authState.redirect.url.

The fields and redirect rails are covered below and can be exercised in sandbox.

Typical approval takes 10 – 30 seconds. The provider will eventually time the prompt out (usually around 60 – 120 seconds) if the customer takes no action.

Redirect-based operators (Wave)

Some operators approve through a redirect rather than a handset prompt — the customer is sent to the operator’s page (a QR code on desktop, an app deep link on mobile), approves there, and is redirected back. Wave (Francophone West Africa) works this way; see Wave for the full flow. Country pages note which of their operators use the redirect flow.


Supported countries

The available operators are determined by the payerDetails.location (ISO 3166 alpha-3) you set when creating a checkout session, filtered by what is enabled for your account — operators are configured during onboarding. On Hosted Checkout the customer sees the right operators automatically.

Pick your country for its operators and any country-specific notes:

CountryOperators
BeninMoov, Celtiis, Wave
Burkina FasoMoov, Orange, Wave
CameroonMTN, Orange
Côte d’IvoireMTN, Orange, Moov, Wave
EthiopiaTelebirr, M-Pesa, CBE Birr, Coopay E-Birr, AwashBirr, YaYa
GabonAirtel, Moov
GhanaMTN, AirtelTigo, Telecel Money
KenyaM-Pesa, Airtel
NigeriaPaga, OPay, PalmPay
RwandaMTN, Airtel
SenegalOrange, Free, Wave
Sierra LeoneOrange
TanzaniaM-Pesa (Vodacom), Airtel, Tigo, Halotel
TogoMoov, Wave
UgandaMTN, Airtel
ZambiaAirtel, MTN, Zamtel

What you collect from the customer

For any mobile money payment, you need three things:

FieldWhere it goesNotes
OperatorpaymentDetails.providerThe operator slug for the payer’s country — see the country page. Present a country-filtered list; don’t ask the customer to scroll through operators that don’t exist in their country.
Mobile numberpaymentDetails.payerMobileNumberLocal or E.164 format. CrissCross normalises to E.164 using the payer’s location, so 0701234567 and +254701234567 both work for a Kenyan payer.
Full namepaymentDetails.payerFullNameSome operators require this; we always pass it through. Falls back to payerDetails.fullName from the session if omitted.

The country code itself comes from payerDetails.location on the session — an ISO 3166 alpha-3 code like KEN, NGA, ZAF. You don’t pass the country code on the payment request again.


Integration options

You can accept mobile money through either of the standard CrissCross integration paths:

  • Hosted Checkout — CrissCross renders the operator selector, mobile number field, and pending-approval screen. Lowest engineering cost.
  • Direct API: Mobile Money — full control. You collect the inputs, call POST /v1/payment, and either poll or rely on webhooks for completion.

The Direct API guide is the most concrete reference for what fields are sent and what responses look like.


Sandbox testing

Sandbox is on by default for every test merchant. Hit https://api.crisscross.money with sandbox credentials and any mobile money request will be routed to the sandbox connector — no real handset, no real operator, no money moves.

The sandbox decides the outcome based on the last digits of the mobile number you submit. The match is on the normalised E.164 form, so the country code doesn’t matter: 0970000005, +27970000005, and +254970000005 all trigger the same scenario when sent with a payerDetails.location of ZAF, KEN, etc. Any number not listed below succeeds normally.

Authorisation failures

These numbers cause the transaction to end in a final FAILED status and emit a transaction.failed webhook. The authState.code lets you distinguish reasons.

Mobile numberFailure codeMeaning
0970000005payinInsufficientFundsThe payer’s wallet has insufficient funds.
0970000006customerRejectedThe customer rejected the prompt on their handset.
0970000007timeoutThe customer didn’t respond before the operator timed out.
0970000008accountNotActiveThe mobile money account is not active.
0970000009payinPayerInvalidAccountNo mobile money account exists for this number.
0970000010payinPayerLimitExceededThe payer’s daily limit would be exceeded.
0970000011monthlyLimitExceededThe payer’s monthly limit would be exceeded.
0970000014transactionNotFoundThe provider can’t locate the transaction.

System errors

These return an authState with state: "error" rather than state: "failed". Retry semantics differ — see the Direct API guide for which can be retried.

Mobile numberError codeMeaning
0970000012internalErrorA CrissCross-side error. Not retryable on the same transaction.
0970000013downstreamErrorThe provider returned an error. Retryable.

Transient errors (test your retry logic)

These numbers misbehave for the first ~2 seconds after the transaction is created, then succeed. They’re useful for verifying that your polling and retry code is resilient.

Mobile numberBehaviour
0970000015POST /v1/payment errors with downstreamError for 2 seconds, then succeeds on the next attempt.
0970000016The transaction is created normally, but GET /v1/payment/{transactionId} errors with downstreamError for 2 seconds before returning a normal authorised state.
0970000017The first execution fails; if your account has a fallback processor configured for the same method, the fallback attempt succeeds.

Delayed confirmations

These numbers stay unconfirmed for a fixed window before completing, simulating a customer who is slow to approve on their handset. Combined with a session expiry shorter than the window, they exercise the late-debit path — the confirmation arrives after the session has expired, triggering the automatic-refund flow. The refund pays out to the same number, and some numbers double as payout-failure triggers, so they differ in what happens to that refund:

Mobile numberBehaviour
0970000018Confirms after ~5 minutes. As a late debit, the automatic refund fails (accountNotActive).
0970000019Confirms after 30 seconds — too close to the 30-second minimum session expiry to exercise the late-debit path reliably.
0970000020Confirms after 1 minute. As a late debit, the automatic refund fails (recipient limit reached).
0970000026Confirms after 1 minute. As a late debit, the automatic refund succeeds — use this one to test the full late-debit → auto-refund path end to end.

OTP authentication

Some rails ask the customer to confirm the payment with a one-time PIN inside your checkout rather than on their handset. Use this number to exercise that flow end to end:

Mobile numberBehaviour
0970000021OTP required.

The transaction returns an authState of authMethodType: "fields" asking for a single otp field, exactly as production OTP rails do. Submit the code via POST /v1/payment/authorize (Hosted Checkout renders the input automatically):

  • 123456 — the payment is confirmed and completes shortly after.
  • Any other code — the payment fails with invalidPin, mirroring production wrong-OTP behaviour (there is no re-prompt).
  • Submit nothing — the transaction expires when the session does, exercising the OTP-timeout path.

Redirect authentication

Some wallets authenticate the customer on the operator’s own page or app instead of an in-session OTP (see Redirect-based operators). These numbers return an authState of authMethodType: "redirect":

There are two kinds of redirect test number — interactive ones that send the customer to a sandbox provider simulation page with Approve and Decline buttons (standing in for the wallet’s real authorisation page), and timed ones where the provider page is never seen and the outcome resolves automatically:

Mobile numberBehaviour
0970000022Timed: the redirect url is your returnUrl (the customer round-trips straight back) and the payment completes automatically about 10 seconds after initiation. Works headlessly.
0970000023Interactive, with requiresTopLevel: true (deep-link wallets that must open outside an iframe): the customer decides on the provider page. Never auto-resolves.
0970000024Timed: like 0970000022 but the payment fails automatically after about 10 seconds. Works headlessly.
0970000025Interactive: the customer decides on the provider page. Never auto-resolves — ignoring the page is the abandoned-redirect path (the payment stays unresolved until the session expires).

On the interactive numbers the customer’s choice is the sole resolver, whichever integration type you use — there is no timer to race. After deciding, a Hosted Checkout customer is returned to the checkout, and a Direct API customer is sent to your session’s redirectUrl with a status query parameter appended (complete or failed) — provide one on session creation so the round-trip has somewhere to land.

The timed numbers exist for automated suites: no browser is required, and the outcome is delivered asynchronously through polling or the transaction.completed / transaction.failed webhook — the same way production wallet redirects resolve.

Successful payment

Any number not listed above completes the happy path: POST /v1/payment returns PENDING, the transaction moves to AUTH_REQUIRED with authState.authMethodType: "pendingApproval", and the next GET /v1/payment/{transactionId} returns COMPLETED with a processorReference like PROC-<transactionId>.


Failure and error reference

The full set of authState.code values you can see on a mobile money transaction:

CodeGroupDescription
payinInsufficientFundsAuth failureWallet balance too low.
customerRejectedAuth failureCustomer declined on handset.
timeoutAuth failureCustomer didn’t respond in time.
accountNotActiveAuth failureWallet exists but is inactive / suspended.
payinPayerInvalidAccountAuth failureNo wallet for this number on the chosen operator.
payinPayerLimitExceededAuth failureDaily transaction limit would be exceeded.
monthlyLimitExceededAuth failureMonthly transaction limit would be exceeded.
transactionNotFoundAuth failureProvider can’t locate the transaction (rare).
declinedAuth failureCatch-all decline from the operator.
invalidPinAuth failureThe customer entered a wrong OTP / PIN.
internalErrorErrorCrissCross-side error.
downstreamErrorErrorOperator returned an error. May be retryable.

Auth failures are terminal for the transaction — the customer would need to retry with a fresh payment. Errors marked as retryable in the response (authState.canRetry) can be re-executed against the same checkout session.


Next steps

  • Find your country in the supported countries table for its operators and notes.
  • Walk through the end-to-end API sequence in Direct API: Mobile Money.
  • Configure your webhook subscription to receive transaction.completed, transaction.failed, transaction.errored, transaction.cancelled, and transaction.expired — see Webhooks.