Your First Payment

Run one complete payment in the sandbox, end to end

This is the one test to run before any other. It takes a single mobile money payment all the way to COMPLETED and proves four things at once: your credentials work, you can reach the API, your request shape is accepted, and you can read a terminal state back.

Everything afterwards is a variation on these calls. Get this working first — a failure here is almost always credentials or request shape, and is far easier to diagnose on the happy path than inside a scenario you are also trying to trigger.

The example uses Kenya as the country and M-Pesa as the mobile network operator, which appear in the requests as currency KES and provider mpesa. Substitute your own market from the market reference if you prefer; the steps do not change.

This page is a test procedure. For field-by-field reference — every optional field, every response shape, the full operator list — see Direct API: Mobile Money.

Step 1 — Get an access token

POST /v1/auth/oauth2/token — API reference

curl -X POST 'https://api.crisscross.money/v1/auth/oauth2/token' \
-H 'Content-Type: application/json' \
--data-raw '{
"client_id": "YOUR_SANDBOX_CLIENT_ID",
"client_secret": "YOUR_SANDBOX_CLIENT_SECRET"
}'
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 86400
}

Pass: a 200 with an access_token. Tokens last 24 hours and there is no refresh token — request a new one when it expires.

If this fails: a 401 with {"error": "Invalid client credentials"} means the client_id or client_secret is wrong, and the two are deliberately indistinguishable so you cannot tell which. A 400 usually means the body was form-encoded; it must be JSON. See Authentication.

Step 2 — Create a checkout session

POST /v1/checkout/session — API reference

curl -X POST 'https://api.crisscross.money/v1/checkout/session' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
--data-raw '{
"merchantId": "YOUR_SANDBOX_MERCHANT_ID",
"merchantReference": "SANDBOX-FIRST-TEST-001",
"amount": 250000,
"currency": "KES",
"integrationType": "direct",
"payerDetails": {
"emailAddress": "[email protected]",
"fullName": "Test Payer",
"location": "KEN",
"phoneNumber": "+254712345678"
}
}'
{
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"payerId": "2a1c4f1a-0d44-4f16-8c8e-9a3b4c5d6e7f"
}

Pass: a 201 with a sessionId. Keep it — the next call needs it.

Two things worth getting right the first time, because they cause most early failures:

  • amount is in minor units. 250000 KES is KSh 2,500.00, but UGX, XAF and XOF have no minor unit, so the same 250000 is 250,000 whole units.
  • payerDetails.location is ISO 3166 alpha-3: KEN, not KE. It decides which operators are valid and normalises the mobile number.

Step 3 — Initiate the payment

POST /v1/payment — API reference

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": "0712345678",
"payerFullName": "Test Payer"
}
}'
{
"transactionId": "9f3e4b2c-1a6d-4e88-9d3a-ff1234567890",
"sessionId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
"merchantReference": "SANDBOX-FIRST-TEST-001",
"paymentMethodId": "mobilemoney",
"status": "PENDING",
"message": "Transaction pending"
}

Pass: a transactionId and a status of PENDING.

0712345678 is the number from step 2, written in local format. Local format and E.164 both work, and the trigger numbers use local format too.

It isn’t a trigger number, so this payment will succeed. That’s what you want from a first test.

If this fails: check that paymentMethodId is "mobilemoney"; the operator goes in paymentDetails.provider. A 409 Conflict means the session already has an active transaction, so create a new session.

Step 4 — Poll to a terminal state

GET /v1/payment/{transactionId} — API reference

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

The first response is usually AUTH_REQUIRED. For a real customer, this is while the prompt is on their phone:

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

Keep polling. The status stays AUTH_REQUIRED for about 10 seconds while CrissCross checks the payment with the operator. Poll every 2 seconds for the first 30 seconds, then every 5–10 seconds, and stop after about 3 minutes. Once it completes:

{
"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"
}
}

Pass: status reaches COMPLETED and a processorReference is present.

AUTH_REQUIRED is the normal waiting state for mobile money, not an error. Show it in your UI as “check your phone”.

Stop polling at COMPLETED, FAILED, CANCELLED or EXPIRED. For ERRORED, check authState.canPoll: if it’s true, only the status check failed and the payment may still complete, so keep polling. Otherwise, stop.

What you have just proved

CredentialsYour sandbox client_id and client_secret issue a working token
ConnectivityNothing between you and api.crisscross.money is blocking the calls
Request shapeSession and payment bodies are accepted as written
State handlingYou can read a transaction through PENDING → AUTH_REQUIRED → COMPLETED

You’ve now proved the happy path. The rest of this section covers failures and the other approval flows.

If you are using Hosted Checkout

Steps 1 and 2 are the same, except you set integrationType to "hosted" on the session. The response includes a paymentLink. Open it and pay on the hosted page instead of calling POST /v1/payment in step 3.

Because you didn’t create the payment yourself, you don’t have its transactionId. Get it from the session once the payment has started:

GET /v1/checkout/session — API reference

curl -G 'https://api.crisscross.money/v1/checkout/session' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
--data-urlencode 'sessionId=0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a'

Take activeTransaction.transactionId from the response and poll it as in step 4. In production, you’d use your webhook for the final status instead of polling.

Next

  • Set up a webhook endpoint and re-run this test. Webhooks are the source of truth for a payment’s final status; polling just keeps your UI up to date. See Testing Webhooks for how to inspect and resend deliveries.
  • Work through the approval flow your markets use. Most mobile money operators use the handset prompt you just saw, but some use an in-checkout OTP and some redirect the customer to the operator’s page.
  • Then vary the number to produce failures, using the trigger tables.