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

# Webhook Events

> Every webhook event CrissCross sends, the body each one carries, and example payloads.

This page covers which events CrissCross sends and what each one contains. For subscribing an endpoint, verifying signatures, retries, and replay, see [Webhooks](/core-concepts-webhooks).

### Event Types

CrissCross sends webhook events for transactions (payments in), refunds, payouts (payments out), payout beneficiaries, and rate locks.

**Transaction event types:**

| Event Type                        | Description                                                                                                                                                                                                                                                                                 |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transaction.completed`           | Transaction was successfully completed                                                                                                                                                                                                                                                      |
| `transaction.failed`              | Transaction attempt failed authorisation                                                                                                                                                                                                                                                    |
| `transaction.errored`             | Transaction encountered an error                                                                                                                                                                                                                                                            |
| `transaction.cancelled`           | Transaction was cancelled                                                                                                                                                                                                                                                                   |
| `transaction.expired`             | Transaction expired                                                                                                                                                                                                                                                                         |
| `transaction.settled`             | Transaction settlement completed                                                                                                                                                                                                                                                            |
| `transaction.refunded`            | The transaction was refunded in full. A partial refund leaves the transaction open and does not fire this event — track those with the `refund.*` events below. An auto-refunded late payment does not fire it either; that flow signals through `transaction.auto_refunded` and `refund.*` |
| `transaction.held_pending_review` | A payment arrived after its order was already cancelled or expired, and is held for manual review                                                                                                                                                                                           |
| `transaction.auto_refunded`       | A payment arrived after its order was already cancelled or expired, and an automatic refund was started                                                                                                                                                                                     |
| `transaction.auto_refund_failed`  | An automatic refund of a late payment could not be completed; the transaction is held for manual review                                                                                                                                                                                     |
| `transaction.released`            | A held transaction was released and is now treated as completed                                                                                                                                                                                                                             |

**Refund event types:**

| Event Type         | Description                                                              |
| ------------------ | ------------------------------------------------------------------------ |
| `refund.completed` | Refund was successfully completed and returned to the original payer     |
| `refund.failed`    | Refund failed (e.g. insufficient merchant balance or provider rejection) |
| `refund.errored`   | Refund encountered an error                                              |
| `refund.cancelled` | Refund was cancelled                                                     |

These are the only events a refund emits, however CrissCross executes it. Many mobile money providers expose payouts but not refund APIs, so a mobile money refund is often [executed as a payout](/full-and-partial-refunds#how-crisscross-executes-refunds) back to the payer's number — that routing is internal, and no `payout.*` event fires for it. `payout.*` events fire only for payouts you create through the Payouts API.

Every refund ends in exactly one terminal event — `refund.completed`, `refund.failed`, `refund.errored`, or `refund.cancelled` — and that event is the authoritative final result of the refund. When a completed refund leaves the original payment refunded in full, `transaction.refunded` follows on the original payment as a supplementary signal (a partial refund does not fire it, and neither does an automatic late-payment refund — that original signals through `transaction.auto_refunded` instead). It is sent after the refund's terminal event, but retries can deliver events out of order, so act on the terminal `refund.*` event and handle `transaction.refunded` independently. As with every event, the same delivery can arrive more than once — deduplicate on the `svix-id` header (see [Webhooks](/core-concepts-webhooks)).

**Payout event types:**

| Event Type         | Description                                        |
| ------------------ | -------------------------------------------------- |
| `payout.completed` | Payout was successfully delivered to the recipient |
| `payout.failed`    | Payout failed (e.g. invalid recipient account)     |
| `payout.errored`   | Payout encountered a retryable error               |
| `payout.cancelled` | Payout was cancelled                               |
| `payout.expired`   | Payout expired                                     |

**Payout beneficiary event types:**

| Event Type                            | Description                                                                         |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| `payout_beneficiary.validated`        | Beneficiary passed validation and screening and is ready to be paid out to          |
| `payout_beneficiary.partial_match`    | Beneficiary returned a partial account-name match and is awaiting your confirmation |
| `payout_beneficiary.screening_review` | Beneficiary requires manual compliance review before it can be used                 |
| `payout_beneficiary.rejected`         | Beneficiary was rejected, either by validation or by screening                      |

**Rate lock event types:**

| Event Type            | Description                                                                               |
| --------------------- | ----------------------------------------------------------------------------------------- |
| `rate_lock.expired`   | A [rate lock](/rate-locks) reached its `expiresAt` — fetch a new lock to keep transacting |
| `rate_lock.cancelled` | A rate lock was cancelled before expiry due to market drift — fetch a new lock            |

Rate lock events are the exception on this page: they carry a rate-lock payload wrapped in an envelope, `{ eventType, timestamp, payload }`, so for those two events the event type *is* readable from the body. See [Rate Locks](/rate-locks) and the Rate Lock Webhooks API reference for the schema. Every other event on this page uses one of the bodies below, which carry no envelope.

### Body Types

Every event above except the rate lock ones sends one of four bodies, delivered as the HTTP body with no envelope around it. Which one you receive depends on the event:

| Body                                          | Events                                                                                                                                       |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| [Transaction](#transaction-body)              | `transaction.completed`, `.failed`, `.errored`, `.cancelled`, `.expired`, `.settled`, `.refunded`, and every `refund.*` and `payout.*` event |
| [Late payment summary](#late-payment-summary) | `transaction.held_pending_review`, `.auto_refunded`, `.auto_refund_failed`                                                                   |
| [Release summary](#release-summary)           | `transaction.released`                                                                                                                       |
| [Beneficiary](#beneficiary)                   | every `payout_beneficiary.*` event                                                                                                           |

#### Transaction body

The transaction the event is about. For a `refund.*` event this is the refund itself; for `transaction.refunded` it is the original payment.

| Field                           | Type           | Presence | Description                                                                                                                                                                                                               |
| ------------------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transactionId`                 | string         | Always   | Identifier of this transaction                                                                                                                                                                                            |
| `status`                        | string         | Always   | Current status — branch on this. It is the event name's suffix in uppercase: `transaction.completed` delivers `COMPLETED`, `transaction.refunded` delivers `REFUNDED`, and so on for every event carrying this body       |
| `message`                       | string         | Always   | Status message. Only meaningful on success; see the note below                                                                                                                                                            |
| `merchantReference`             | string         | Optional | Your reference from the original session                                                                                                                                                                                  |
| `sessionId`                     | string (uuid)  | Optional | Session the transaction belongs to                                                                                                                                                                                        |
| `paymentMethodId`               | string         | Optional | Payment method used                                                                                                                                                                                                       |
| `identifiers`                   | object         | Optional | Usually `sessionId` and `payerId` (see [Payer ID](/payer-id))                                                                                                                                                             |
| `paymentAttributes`             | object         | Optional | The attributes you set on the session, plus `merchant_type`, which CrissCross adds on every transaction and payout: `"sandbox"` or `"real"`. This is the only field in the delivery that tells the two environments apart |
| `processorName`                 | string         | Optional | Display name of the payment processor                                                                                                                                                                                     |
| `processorReference`            | string \| null | Nullable | Processor's reference. `null` until the processor returns one                                                                                                                                                             |
| `financialTransactionReference` | string \| null | Nullable | Processor's financial reference. `null` when it supplies none                                                                                                                                                             |
| `currentAttemptId`              | string         | Optional | Identifier of the current attempt                                                                                                                                                                                         |
| `authState`                     | object         | Optional | Detail about the current state — see [below](#authstate)                                                                                                                                                                  |

**Nullable** fields arrive as an explicit `null` when unset. **Optional** fields are left out of the body entirely. Parse accordingly.

FX payments carry `collection`, `fxMidRate`, `fxAllInRate`, `fxMarkupBps`, and `fxFee` as well. See [Adaptive Currency Conversion](/adaptive-currency-conversion).

#### authState

`authState` describes the current state in more detail. What it carries depends on the status — when it is present, `state` and `transitionedAt` always are:

| `status`    | `authState.state` | Also carries                                                         |
| ----------- | ----------------- | -------------------------------------------------------------------- |
| `COMPLETED` | `completed`       | `message`                                                            |
| `FAILED`    | `failed`          | `message`, `code`, `connectorFailureCode`, `connectorFailureMessage` |
| `ERRORED`   | `error`           | `message`, `code`, `connectorFailureCode`, `connectorFailureMessage` |
| `CANCELLED` | `cancelled`       | nothing further                                                      |
| `EXPIRED`   | `expired`         | nothing further                                                      |
| `SETTLED`   | `settled`         | nothing further                                                      |
| `REFUNDED`  | —                 | `authState` is omitted from the body entirely                        |

Things to watch:

* **`REFUNDED` bodies have no `authState` at all.** A handler that assumes the key exists will break on `transaction.refunded`.
* **`ERRORED` reports `authState.state` as `error`**, not `errored`. Match on the value, not on the event name.
* **`code` uses different values for `FAILED` than for `ERRORED`** — authentication failure codes versus error codes. See [Error Codes](/error-codes).
* **The top-level `message` is not a failure reason.** On `FAILED`, `ERRORED`, `CANCELLED`, `EXPIRED`, `SETTLED`, and `REFUNDED` it is always the string `"Transaction queried successfully"`. Take the reason from `authState.message` and `authState.code`.
* **`SETTLED` carries no settlement identifier.** Use the [settlement endpoints](/settlement-reporting) to find which settlement a transaction landed in.

#### Late payment summary

Sent when a payment arrives after its order was already cancelled or expired. It is a summary, not the full transaction — call [Retrieve Payment Status](/api-reference/collect/payments/manage-payments/get-transaction) if you need the rest.

| Field           | Type   | Presence | Description                                                                                                                                           |
| --------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transactionId` | string | Always   | The payment that arrived late                                                                                                                         |
| `reason`        | string | Always   | Why the order was closed: `merchant_cancellation`, `session_expired`, or `hold_timeout`. On `transaction.auto_refund_failed` it can also be `unknown` |

#### Release summary

Sent when a held transaction is released and treated as completed.

| Field           | Type   | Presence | Description                       |
| --------------- | ------ | -------- | --------------------------------- |
| `transactionId` | string | Always   | The transaction that was released |

#### Beneficiary

The payout beneficiary the event is about. `status` always matches the event.

| Field                 | Type   | Presence | Description                                                                                                                                                                                                                            |
| --------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payoutBeneficiaryId` | string | Always   | Identifier of the beneficiary                                                                                                                                                                                                          |
| `merchantId`          | string | Always   | Your merchant identifier                                                                                                                                                                                                               |
| `status`              | string | Always   | `PAYOUT_BENEFICIARY_STATUS_` followed by `VALIDATED`, `PARTIAL_MATCH`, `SCREENING_REVIEW`, or `REJECTED` — one per event. The resource has three further statuses (`CREATED`, `VALIDATING`, `DISABLED`), but no webhook fires for them |
| `type`                | string | Always   | `PAYOUT_BENEFICIARY_TYPE_` followed by `BANK_ACCOUNT`, `MOBILE_MONEY`, `CASH`, or `INSTITUTION_WALLET`                                                                                                                                 |
| `country`             | string | Always   | Beneficiary's country                                                                                                                                                                                                                  |
| `currency`            | string | Always   | Currency the beneficiary is paid in                                                                                                                                                                                                    |
| `validation`          | object | Always   | Account-name check: `verified`, `state` (`PENDING`, `VERIFIED`, `PARTIAL_MATCH`, or `NOT_VERIFIED`), `attempts`, and, when available, `validatedAccountHolderName`, `returnedAccountHolderName`, and `lastError`                       |
| `amlScreening`        | object | Always   | Screening result: `state` (`AML_SCREENING_STATE_` followed by `PENDING`, `CLEARED`, `REVIEW`, `REJECTED`, or `ERROR`), `attempts`, and, when available, `screeningId`, `lastError`, and `reviewReason`                                 |
| `updatedAt`           | string | Always   | When the beneficiary last changed                                                                                                                                                                                                      |

Account-name verification runs for mobile money only. A bank account is screened but not name-checked, so its `validation` stays at `verified: false`, `state: "PENDING"`, `attempts: 0` even once the beneficiary is validated — read `status`, not `validation.verified`, to decide whether it can be paid out to. For the same reason `payout_beneficiary.partial_match` only ever fires for mobile money.

### Example Webhook Payloads

Every delivery arrives with the same headers, and none of them names the event. For the bodies below the event type is not in the body either, so route by subscribing each endpoint to the specific events you want. (Rate lock events are the exception — see [above](#event-types).)

```
content-type:   application/json
svix-id:        msg_EXAMPLE0000000000000000000
svix-timestamp: 1753093800
svix-signature: v1,EXAMPLE_BASE64_SIGNATURE=
```

The bodies below are complete, exactly as delivered.

A completed transaction (`transaction.completed`):

```json
{
  "transactionId": "019b024f-8c57-777f-a97c-fa21a2bdbb40",
  "status": "COMPLETED",
  "message": "Collection successful",
  "merchantReference": "ORDER-2026-0142",
  "identifiers": {
    "payerId": "019b024f-1a2b-7cde-8f01-223344556677",
    "sessionId": "5f1a3b6c-2d4e-4f8a-9b1c-7d2e3f4a5b6c"
  },
  "paymentAttributes": {
    "orderSource": "web",
    "merchant_type": "real"
  },
  "paymentMethodId": "mobilemoney",
  "processorName": "example-processor",
  "processorReference": "PROC_REF_123",
  "financialTransactionReference": null,
  "currentAttemptId": "019b024f-9e8d-7c6b-a5f4-332211009988",
  "sessionId": "5f1a3b6c-2d4e-4f8a-9b1c-7d2e3f4a5b6c",
  "authState": {
    "state": "completed",
    "message": "Collection successful",
    "transitionedAt": "2026-07-21T10:30:00.000Z"
  }
}
```

A completed payout (`payout.completed`):

```json
{
  "transactionId": "019b024f-8c57-777f-a97c-fa21a2bdbb40",
  "status": "COMPLETED",
  "message": "Payout completed",
  "merchantReference": "PAYOUT_550e8400-e29b-41d4-a716-446655440000",
  "paymentMethodId": "mobilemoney",
  "identifiers": {},
  "paymentAttributes": { "merchant_type": "real" },
  "processorReference": "LK_REF_123",
  "currentAttemptId": "ATTEMPT_456",
  "authState": {
    "state": "completed",
    "transitionedAt": "2025-07-21T11:00:00.000Z",
    "message": "Payout completed"
  }
}
```

A completed refund (`refund.completed`). This is the refund's own transaction, not the original payment:

```json
{
  "transactionId": "019b0250-4c11-7a3e-b8d2-77aa11bc9f04",
  "status": "COMPLETED",
  "message": "Refund successful",
  "merchantReference": "ORDER-2026-0142",
  "identifiers": {
    "payerId": "019b024f-1a2b-7cde-8f01-223344556677",
    "sessionId": "5f1a3b6c-2d4e-4f8a-9b1c-7d2e3f4a5b6c"
  },
  "paymentAttributes": {
    "orderSource": "web",
    "merchant_type": "real"
  },
  "paymentMethodId": "mobilemoney",
  "processorName": "example-processor",
  "processorReference": "PROC_REF_456",
  "financialTransactionReference": null,
  "currentAttemptId": "019b0250-4c11-7a3e-b8d2-88bb22cd0a15",
  "sessionId": "5f1a3b6c-2d4e-4f8a-9b1c-7d2e3f4a5b6c",
  "authState": {
    "state": "completed",
    "message": "Refund successful",
    "transitionedAt": "2026-07-22T09:12:44.000Z"
  }
}
```

A refund copies the original payment's `merchantReference`, `identifiers`, `paymentMethodId`, and `paymentAttributes`, but it is its own transaction: `transactionId`, `message`, `processorReference`, `currentAttemptId`, and `authState` are the refund's, not the payment's. There is no original-transaction id or transaction type in the body. So:

* **Match** a refund to its payment from your own records: when you [create the refund](/full-and-partial-refunds), persist the returned `transactionId` together with the response's `originalTransactionId`, and look the webhook's `transactionId` up against that. `merchantReference` and `identifiers.sessionId` are not reliable keys — a session can hold more than one refundable transaction.
* **Tell the two records apart** by `transactionId` — keying on `merchantReference` alone risks double-counting.
* **Give `refund.*` its own endpoint.** Nothing in the body separates it from `transaction.*`.

A late payment that arrived against a cancelled order and was automatically refunded (`transaction.auto_refunded`). `transaction.held_pending_review` and `transaction.auto_refund_failed` carry the same two fields:

```json
{
  "transactionId": "019b024f-8c57-777f-a97c-fa21a2bdbb40",
  "reason": "merchant_cancellation"
}
```

A held transaction that was released (`transaction.released`) — the same shape without `reason`:

```json
{
  "transactionId": "019b024f-8c57-777f-a97c-fa21a2bdbb40"
}
```

A mobile money beneficiary that passed validation and screening (`payout_beneficiary.validated`). All four `payout_beneficiary.*` events carry this same object, with `status` matching the event:

```json
{
  "payoutBeneficiaryId": "0d3f1d6c-1c49-4b89-9db6-1fdc06f24c8a",
  "merchantId": "ACME",
  "status": "PAYOUT_BENEFICIARY_STATUS_VALIDATED",
  "type": "PAYOUT_BENEFICIARY_TYPE_MOBILE_MONEY",
  "country": "KE",
  "currency": "KES",
  "validation": {
    "verified": true,
    "validatedAccountHolderName": "A Mwangi",
    "returnedAccountHolderName": "A Mwangi",
    "state": "VERIFIED",
    "attempts": 1
  },
  "amlScreening": {
    "state": "AML_SCREENING_STATE_CLEARED",
    "screeningId": "scr_01HX5GMR3K8YBQ5VCRD7P3F9Z2",
    "attempts": 1
  },
  "updatedAt": "2026-06-02T10:30:14.000Z"
}
```

`status`, `type`, and `amlScreening.state` are prefixed enum names rather than bare words, so compare against the full string — see [Beneficiary](#beneficiary) for the values. The error and reason fields are omitted when empty, so expect them on `partial_match`, `screening_review`, and `rejected` rather than on `validated`.