Direct API: Mobile Money
Direct API: Mobile Money
End-to-end Direct API integration for mobile money payments
Overview
This guide walks through a complete mobile money payment using CrissCross’s Direct API. It assumes you’ve decided not to use Hosted Checkout or our SDK, and want to drive the flow yourself.
For the conceptual overview, supported operators, and sandbox test numbers, see Mobile Money. This page is the request/response reference.
Prerequisites
- CrissCross account with a
merchantIdand API credentials. See Authentication for how to obtain a bearer token. - A webhook endpoint reachable over HTTPS, subscribed to transaction events. See Webhooks for how subscriptions are managed.
- Sandbox credentials for testing. The same endpoints serve both sandbox and live — your bearer token routes the request to the right environment.
All requests in this guide go to https://api.crisscross.money/v1.
The flow at a glance
A mobile money payment touches five endpoints. Steps 2 and 5 are optional but recommended:
POST /v1/checkout/session— Create a session for the payment. → returnssessionId.- (Optional)
GET /v1/payment/available-methods?sessionId=...— Check which operators are available for this payer. Useful for rendering the operator selector. POST /v1/payment— Initiate the transaction. → returnstransactionId, transaction enterspending.- Webhook +
GET /v1/payment/{transactionId}— Wait for the customer to approve on their handset. The terminal state arrives by webhook; poll if you need an interactive UI. - (Optional)
POST /v1/payment/refunds— Refund the payment later. See Refunds.
For most operators POST /v1/payment/authorize is not part of the flow — approval happens on the customer’s handset. The exceptions are OTP rails (the transaction asks for a one-time PIN collected in your checkout and submitted via POST /v1/payment/authorize) and redirect rails (the customer authenticates on the operator’s page). Both are covered in OTP and redirect rails below.
Step 1 — Create a checkout session
Response
Keep the sessionId. You’ll use it for the next two calls. A session can only have one active transaction at a time — POST /v1/payment will return 409 Conflict if you try to initiate a second one against the same session.
The response also returns a payerId — a persistent identifier for this customer. Store it against your customer record and pass it back in payerDetails.payerId on future sessions to recognise a returning payer. See Payer ID.
Step 2 — (Optional) Get available payment methods
If you want to render an operator picker driven by what’s actually configured for the payer’s country, ask the API:
Response
A few things to note:
- The
typefield is always"mobilemoney"— there is no"mtn-momo"or"mobile-money"variant. Operator selection happens insidepaymentDetails.provideron the payment request. - The
optionslist underprovideris country-filtered based onpayerDetails.locationfrom the session. For Kenya you’ll only see M-Pesa; for Côte d’Ivoire you’ll see MTN, Orange, Moov, and Wave. - The full list of operator slugs and per-country availability is on the Mobile Money page.
You can skip this call entirely if you already know which operator the customer is using.
Step 3 — Initiate the payment
Response
At this point CrissCross has sent the request to the operator, which has dispatched the push / USSD prompt to the customer’s handset. The customer now has to approve.
Tell your customer what’s happening. A mobile money pending screen should say something like “Check your phone — we’ve sent an MTN prompt to +254 ••• •••• 567. Enter your MoMo PIN to approve.” If you skip this, customers often think the page is broken and abandon.
Step 4 — Wait for the terminal state
You have two options, and most integrations use both. Webhooks are the source of truth; polling is for keeping your customer-facing UI responsive while you wait.
Webhooks
Subscribe to these event types on your webhook endpoint:
Example completed payload:
Match incoming events to the transaction using payload.transactionId (preferred) or payload.merchantReference. Verify the webhook signature on every delivery — see Webhooks → Securing webhooks.
Polling
If you need to update the customer-facing screen as soon as approval happens (rather than waiting for the webhook to land on your server and round-trip back), poll:
Response while the customer is approving:
Response after the customer approves:
Recommended polling cadence: every 2 seconds for the first 30 seconds, then back off to every 5 – 10 seconds. Stop polling once status is no longer pending, or after ~3 minutes total — at that point trust the webhook. Customers usually approve within 10 – 30 seconds.
Always reconcile against the webhook. The handset prompt can be approved or rejected after your polling timeout, and providers occasionally back-date the confirmation. The transaction’s final state is whatever the most recent webhook for that transactionId says.
OTP and redirect rails
Some rails don’t use the handset prompt. Instead of pending_approval, the transaction lands in AUTH_REQUIRED with an authState describing what the customer must do next. Detect these on the poll response and handle them before falling back to the normal wait loop.
OTP — authState.authMethodType: "fields" with a single otp field (and an optional buyer-facing instruction to render above the input). Collect the code from the customer and submit it:
A correct code moves the payment to confirmation (poll or wait for the webhook as usual); a wrong code fails the transaction with authState.code: "INVALID_PIN" — there is no re-prompt, the customer must start a fresh payment. If the customer never submits, the transaction expires with the session.
Redirect — authState.authMethodType: "redirect" with a url to send the customer to (and requiresTopLevel: true when the URL deep-links to a wallet app and must not be iframed). Provide a redirectUrl on the session so the customer can be sent back to you afterwards — after authorising on the operator’s page (in sandbox, the interactive test numbers serve a provider simulation page with Approve/Decline buttons; the timed numbers skip it) the customer is redirected there with a status query parameter appended. The outcome arrives asynchronously through the poll/webhook channel once the customer completes (or abandons) the operator’s page — there is nothing to submit yourself.
In sandbox, 0970000021 exercises the OTP rail and 0970000022 – 0970000025 the redirect rail — see Testing below.
Handling failures
If the customer rejects, times out, or has insufficient funds, the transaction ends in status: FAILED with authState.type: "auth_failure" and a specific code:
Common codes you’ll see for mobile money:
An auth failure is terminal for the transaction. To retry, create a new transaction on the same sessionId (or a fresh session if it has expired).
Handling errors
authState.type: "error" is distinct from auth_failure — it means CrissCross or the operator hit a system error, not that the payment was declined. The response carries hints about how to recover:
canRetry: true— Re-initiate the transaction. If your account has a fallback processor configured for the same operator, CrissCross will route the retry there.canPoll: true— The error came from a polling attempt; keep polling, the transaction may still complete.- Neither flag set — Treat as terminal and surface a generic “Something went wrong” to the customer.
Idempotency and duplicate prevention
- A
sessionIdcan only have one active transaction at a time. RepeatedPOST /v1/paymentcalls with the samesessionIdreturn409 Conflictwhile the first transaction is still live, which is the intended way to prevent duplicate charges if your client retries. - For the same logical order, reuse the same
merchantReferenceon the session — this gives you a stable identifier for reconciliation viaGET /v1/payment/search?merchantReference=.... - Webhooks may be delivered more than once. Deduplicate on
eventIdin your handler. See Webhooks → Best practices.
Testing
Sandbox responses are driven by the last digits of payerMobileNumber. The full table of test numbers is on the Mobile Money page — short version:
- Any number not in the table → success.
0970000005–0970000011,0970000014→ specific auth failure codes.0970000012,0970000013→ system errors.0970000015–0970000017→ transient errors for exercising retry logic.0970000018–0970000020→ delayed confirmations (5 min / 30 s / 1 min) — pair0970000018with a short session expiry to exercise the late-debit / auto-refund path.0970000021→ OTP rail: submit123456viaPOST /v1/payment/authorizeto complete, any other code to fail withINVALID_PIN.0970000022,0970000024→ timed redirect rail: the redirecturlis yourreturnUrl(no provider page) and the payment auto-completes / auto-fails ~10 s after initiation. Use these for headless suites.0970000023,0970000025→ interactive redirect rail: the redirecturlis a sandbox provider simulation page with Approve/Decline buttons (0970000023withrequiresTopLevel: true); the customer’s decision is the sole resolver — never auto-resolves — and sends them back to your session’sredirectUrlwith astatusparam.
Use a real payerDetails.location for the country code you’re testing — the number is normalised against it, so 0970000005 with "location": "KEN" and 0970000005 with "location": "NGA" produce the same failure but on different normalised numbers.
Endpoint reference
For the full schemas of every request and response, see the Payments API reference.