> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.crisscross.money/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.crisscross.money/_mcp/server.

# Direct API: Mobile Money

> Concrete request/response walkthrough for accepting a mobile money payment with CrissCross's Direct API — checkout session, payment initiation, polling, webhooks, and error handling.

### 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](/hosted-checkout), and want to drive the flow yourself.

For the conceptual overview, supported operators, and sandbox test numbers, see [Mobile Money](/payment-method-mobile-money). This page is the request/response reference.

#### Prerequisites

* CrissCross account with a `merchantId` and API credentials. See [Authentication](/authentication) for how to obtain a bearer token.
* A webhook endpoint reachable over HTTPS, subscribed to transaction events. See [Webhooks](/core-concepts-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/{transactionId}/refund`** — Refund the payment later. See [Refunds](/full-and-partial-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](/payment-method-mobile-money#supported-countries-and-operators) 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](#otp-and-redirect-rails) below.

---

### Step 1 — Create a checkout session

```bash
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": "frank.mwangi@example.com",
      "fullName": "Frank Mwangi",
      "location": "KEN",
      "phoneNumber": "+254701234567"
    }
  }'
```

| Field                       | Required                   | Notes                                                                                                                                                                                                                                                                                              |
| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `merchantId`                | yes                        | UUID of the merchant account.                                                                                                                                                                                                                                                                      |
| `merchantReference`         | yes                        | Your order reference. Use alphanumeric — some providers strip non-alphanumeric characters, which can break reconciliation.                                                                                                                                                                         |
| `amount`                    | yes                        | Integer, in minor units of the currency. `250000` KES = KSh 2,500.00.                                                                                                                                                                                                                              |
| `currency`                  | yes                        | ISO 4217 code. Must match a currency the chosen operator supports. With a `rateLockId`, must equal the lock's base currency instead.                                                                                                                                                               |
| `integrationType`           | yes                        | `"direct"` for this flow.                                                                                                                                                                                                                                                                          |
| `rateLockId`                | optional                   | Apply a [rate lock](/rate-locks): 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.emailAddress` | recommended                | Used for receipts and provider records.                                                                                                                                                                                                                                                            |
| `payerDetails.location`     | yes                        | ISO 3166 alpha-3 country code. Determines which operators are valid and is used to normalise the phone number.                                                                                                                                                                                     |
| `payerDetails.fullName`     | recommended                | Falls through to the payment if you omit `payerFullName` later.                                                                                                                                                                                                                                    |
| `payerDetails.phoneNumber`  | **required for this flow** | Accepts 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

```json
{
  "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](/payer-id).

---

### Step 2 — Initiate the payment

```bash
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"
    }
  }'
```

| Field                              | Required    | Notes                                                                                                                                                                                                                                                                                                   |
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessionId`                        | yes         | From step 1.                                                                                                                                                                                                                                                                                            |
| `paymentMethodId`                  | yes         | Always the literal string `"mobilemoney"`. Don't substitute the operator name here — that goes in `paymentDetails.provider`.                                                                                                                                                                            |
| `paymentDetails.type`              | yes         | Always `"mobilemoney"`. Matches the discriminator on the payment details union.                                                                                                                                                                                                                         |
| `paymentDetails.provider`          | yes         | The 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.payerMobileNumber` | yes         | Local or E.164. CrissCross normalises against `payerDetails.location` from the session.                                                                                                                                                                                                                 |
| `paymentDetails.payerFullName`     | recommended | Some providers reject the transaction without it. If omitted, we fall back to `payerDetails.fullName` from the session.                                                                                                                                                                                 |

#### Response

```json
{
  "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:

| Event                   | What it means                                                                                                                                                                        |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transaction.completed` | Customer approved. Funds are secured with CrissCross. Release goods / fulfill order.                                                                                                 |
| `transaction.failed`    | Customer rejected, timed out, had insufficient funds, etc. Terminal. Inspect `authState.code` for the reason.                                                                        |
| `transaction.errored`   | A 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](#handling-errors)). |
| `transaction.cancelled` | The transaction was cancelled (by you, the customer, or the system).                                                                                                                 |
| `transaction.expired`   | The session expired before approval. Customer needs to start over.                                                                                                                   |
| `transaction.settled`   | Funds 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:

```json
{
  "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](/core-concepts-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:

```bash
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:

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

Response after the customer approves:

```json
{
  "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.

> **Info**
>
> 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.

**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:

```bash
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.

**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](#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`:

```json
{
  "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.code`           | What 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:

```json
{
  "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](/core-concepts-webhooks#best-practices).

---

### Testing

Sandbox responses are driven by `payerMobileNumber` — see [Trigger Numbers](/testing-trigger-numbers) for the full set, and [Testing Overview](/testing-overview) for how to work through them.

Use a real `payerDetails.location` for the country you're testing. The number is normalised against it, so the same trigger number produces the same outcome in every market — but the operator you pass must be valid for that country.

---

### Endpoint reference

| Endpoint                             | Method | Purpose                                                                                                                                                                                                                                                                                                                                   |
| ------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/v1/checkout/session`               | POST   | Create the session.                                                                                                                                                                                                                                                                                                                       |
| `/v1/payment`                        | POST   | Initiate the mobile money transaction.                                                                                                                                                                                                                                                                                                    |
| `/v1/payment/authorize`              | POST   | Submit the customer's OTP — only for OTP rails (see [OTP and redirect rails](#otp-and-redirect-rails)).                                                                                                                                                                                                                                   |
| `/v1/payment/{transactionId}`        | GET    | Retrieve current status (poll).                                                                                                                                                                                                                                                                                                           |
| `/v1/payment/search`                 | GET    | Look up transactions by `merchantReference`.                                                                                                                                                                                                                                                                                              |
| `/v1/payment/{transactionId}/refund` | POST   | Refund a completed mobile money payment. If the provider does not support native refunds, CrissCross returns the funds as a payout to the original number — see [Refunds](/full-and-partial-refunds). Either way the refund is reported only through `refund.*` webhook events, never `payout.*` — see [Webhook Events](/webhook-events). |

For the full schemas of every request and response, see the [Payments API reference](/api-reference).