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, 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 merchantId and 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 four endpoints. Step 4 is optional:

  1. POST /v1/checkout/session — Create a session for the payment. → returns sessionId.
  2. POST /v1/payment — Initiate the transaction. → returns transactionId, transaction enters PENDING.
  3. 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.
  4. (Optional) POST /v1/payment/refunds — Refund the payment later. See Refunds.

The operators you can offer are configured on your account during onboarding. The full list of operator slugs and per-country availability is on the Mobile Money page.

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

curl -X POST 'https://api.crisscross.money/v1/checkout/session' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
--data-raw '{
"merchantId": "1b2a3d4c-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"merchantReference": "ORDER-2026-0142",
"amount": 250000,
"currency": "KES",
"integrationType": "direct",
"payerDetails": {
"emailAddress": "[email protected]",
"fullName": "Frank Mwangi",
"location": "KEN",
"phoneNumber": "+254701234567"
}
}'
FieldRequiredNotes
merchantIdyesUUID of the merchant account.
merchantReferenceyesYour order reference. Use alphanumeric — some providers strip non-alphanumeric characters, which can break reconciliation.
amountyesInteger, in minor units of the currency. 250000 KES = KSh 2,500.00.
currencyyesISO 4217 code. Must match a currency the chosen operator supports. With a rateLockId, must equal the lock’s base currency instead.
integrationTypeyes"direct" for this flow.
rateLockIdoptionalApply a rate lock: price the session in the lock’s base currency (e.g. USD) and the payer is charged in the quote currency (e.g. KES) at the locked rate. Verified here and again at POST /v1/payment — expired or cancelled locks are rejected with 422; fetch a new lock.
payerDetails.emailAddressrecommendedUsed for receipts and provider records.
payerDetails.locationyesISO 3166 alpha-3 country code. Determines which operators are valid and is used to normalise the phone number.
payerDetails.fullNamerecommendedFalls through to the payment if you omit payerFullName later.
payerDetails.phoneNumberrequired for this flowAccepts local or E.164. Optional at the API level (a hosted-checkout payer can enter it on the page), but the Direct API has no such page — supply the payer’s number here (or repeat it as payerMobileNumber on POST /v1/payment). Also used for compliance / fraud screening.

Response

{
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"payerId": "2a1c4f1a-0d44-4f16-8c8e-9a3b4c5d6e7f"
}

Keep the sessionId. You’ll use it to initiate the payment. 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 — Initiate the payment

curl -X POST 'https://api.crisscross.money/v1/payment' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
--data-raw '{
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"paymentMethodId": "mobilemoney",
"paymentDetails": {
"type": "mobilemoney",
"provider": "mpesa",
"payerMobileNumber": "0701234567",
"payerFullName": "Frank Mwangi"
}
}'
FieldRequiredNotes
sessionIdyesFrom step 1.
paymentMethodIdyesAlways the literal string "mobilemoney". Don’t substitute the operator name here — that goes in paymentDetails.provider.
paymentDetails.typeyesAlways "mobilemoney". Matches the discriminator on the payment details union.
paymentDetails.provideryesThe operator slug. One of airtel, airteltigo, awashbirr, cbebirr, celtiis, ebirr, emoney, free, halotel, mobicash, moov, mpesa, mtn, muni, orange, telebirr, telecel, tigo, togocell, vodacom, wave, yaya, zamtel. Must be valid for the payer’s country.
paymentDetails.payerMobileNumberyesLocal or E.164. CrissCross normalises against payerDetails.location from the session.
paymentDetails.payerFullNamerecommendedSome providers reject the transaction without it. If omitted, we fall back to payerDetails.fullName from the session.

Response

{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"merchantReference": "ORDER-2026-0142",
"paymentMethodId": "mobilemoney",
"status": "PENDING",
"message": "Transaction pending"
}

The response carries the transaction’s current status — usually PENDING, but a fast routing failure can come back as FAILED or ERRORED synchronously, so handle any status. Once CrissCross has sent the request to the operator and the push / USSD prompt is on the customer’s handset, the transaction moves to AUTH_REQUIRED with authState.authMethodType: "pendingApproval". 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 3 — 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:

EventWhat it means
transaction.completedCustomer approved. Funds are secured with CrissCross. Release goods / fulfill order.
transaction.failedCustomer rejected, timed out, had insufficient funds, etc. Terminal. Inspect authState.code for the reason.
transaction.erroredA system error. The webhook body doesn’t say whether it’s retryable — poll the transaction and check authState.canRetry in the response (see Handling errors).
transaction.cancelledThe transaction was cancelled (by you, the customer, or the system).
transaction.expiredThe session expired before approval. Customer needs to start over.
transaction.settledFunds have settled to your account. Fires later than completed; only relevant for reconciliation.

Example completed body, exactly as delivered — the payload is the whole body, with no envelope around it:

{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"merchantReference": "ORDER-2026-0142",
"paymentMethodId": "mobilemoney",
"status": "COMPLETED",
"message": "Transaction completed",
"identifiers": {
"payerId": "2a1c4f1a-0d44-4f16-8c8e-9a3b4c5d6e7f",
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a"
},
"paymentAttributes": { "merchant_type": "real" },
"processorName": "safaricom-daraja",
"processorReference": "PROC-9f3e4b2c",
"financialTransactionReference": null,
"authState": {
"state": "completed",
"transitionedAt": "2026-06-02T10:30:14Z",
"message": "Transaction completed"
}
}

Because the body carries no event type, subscribe this endpoint to transaction.* only and route on status. Match incoming events to the transaction using transactionId (preferred) or 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:

curl -G 'https://api.crisscross.money/v1/payment/9f3e4b2c-1a6d-4e88-9d3a-ff1234567890' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'

Response while the customer is approving:

{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"status": "AUTH_REQUIRED",
"message": "Transaction pending",
"authState": { "authMethodType": "pendingApproval" }
}

Response after the customer approves:

{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"status": "COMPLETED",
"message": "Transaction completed",
"processorReference": "PROC-9f3e4b2c",
"authState": {
"state": "completed",
"transitionedAt": "2026-06-02T10:30:44Z",
"message": "Transaction completed"
}
}

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 AUTH_REQUIRED — unless the response carries authState.canPoll: true, which means the error was a polling hiccup and the transaction may still complete, so keep going. Stop after ~3 minutes total either way and 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 authMethodType: "pendingApproval", the transaction’s AUTH_REQUIRED state carries 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.

OTPauthState.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:

curl -X POST 'https://api.crisscross.money/v1/payment/authorize' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
--data-raw '{
"transactionId": "0197a3f1-2b4c-7d5e-8f90-1a2b3c4d5e6f",
"authDataFields": [
{ "name": "otp", "type": "string", "value": "123456" }
]
}'

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: "invalidPin" — there is no re-prompt, the customer must start a fresh payment. If the customer never submits, the transaction expires with the session.

RedirectauthState.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 09700000220970000025 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.state: "failed" and a specific code:

{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"status": "FAILED",
"message": "Insufficient funds",
"authState": {
"state": "failed",
"code": "payinInsufficientFunds",
"message": "The payer's mobile money account has insufficient funds."
}
}

Common codes you’ll see for mobile money:

authState.codeWhat to show the customer
payinInsufficientFunds”Your mobile money balance is too low. Top up and try again.”
customerRejected”Payment cancelled. Try again to retry.”
timeout”You didn’t respond to the prompt in time. Try again.”
accountNotActive”Your mobile money account isn’t active. Contact your operator.”
payinPayerInvalidAccount”We can’t find a mobile money account for that number. Check the number and try again.”
payinPayerLimitExceeded”You’ve hit your daily mobile money limit. Try again tomorrow or use a different payment method.”

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.state: "error" is distinct from failed — it means CrissCross or the operator hit a system error, not that the payment was declined. The response carries hints about how to recover:

{
"authState": {
"state": "error",
"code": "downstreamError",
"message": "Provider returned a transient error",
"canRetry": true
}
}
  • 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 sessionId can only have one active transaction at a time. Repeated POST /v1/payment calls with the same sessionId return 409 Conflict while 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 merchantReference on the session — this gives you a stable identifier for reconciliation via GET /v1/payment/search?merchantReference=....
  • Webhooks may be delivered more than once. Deduplicate on the svix-id header in 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.
  • 09700000050970000011, 0970000014 → specific auth failure codes.
  • 0970000012, 0970000013 → system errors.
  • 09700000150970000017 → transient errors for exercising retry logic.
  • 09700000180970000020, 0970000026 → delayed confirmations (5 min / 30 s / 1 min / 1 min) — pair a delay longer than the session expiry to exercise the late-debit path. Use 0970000026 to see the automatic refund succeed; the refunds for 0970000018 and 0970000020 fail, because those numbers double as payout-failure triggers.
  • 0970000021 → OTP rail: submit 123456 via POST /v1/payment/authorize to complete, any other code to fail with invalidPin.
  • 0970000022, 0970000024 → timed redirect rail: the redirect url is your returnUrl (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 redirect url is a sandbox provider simulation page with Approve/Decline buttons (0970000023 with requiresTopLevel: true); the customer’s decision is the sole resolver — never auto-resolves — and sends them back to your session’s redirectUrl with a status param.

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

EndpointMethodPurpose
/v1/checkout/sessionPOSTCreate the session.
/v1/paymentPOSTInitiate the mobile money transaction.
/v1/payment/authorizePOSTSubmit the customer’s OTP — only for OTP rails (see OTP and redirect rails).
/v1/payment/{transactionId}GETRetrieve current status (poll).
/v1/payment/searchGETLook up transactions by merchantReference.
/v1/payment/refundsPOSTRefund a completed mobile money payment. Refunds for mobile money are processed as payouts to the original number — see Refunds.

For the full schemas of every request and response, see the Payments API reference.