Webhook Events

Event catalogue and payloads

This page covers which events CrissCross sends and what each one contains. For subscribing an endpoint, verifying signatures, retries, and replay, see Webhooks.

Event Types

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

Transaction event types:

Event TypeDescription
transaction.completedTransaction was successfully completed
transaction.failedTransaction attempt failed authorisation
transaction.erroredTransaction encountered an error
transaction.cancelledTransaction was cancelled
transaction.expiredTransaction expired
transaction.settledTransaction settlement completed
transaction.refundedThe 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_reviewA payment arrived after its order was already cancelled or expired, and is held for manual review
transaction.auto_refundedA payment arrived after its order was already cancelled or expired, and an automatic refund was started
transaction.auto_refund_failedAn automatic refund of a late payment could not be completed; the transaction is held for manual review
transaction.releasedA held transaction was released and is now treated as completed

Refund event types:

Event TypeDescription
refund.completedRefund was successfully completed and returned to the original payer
refund.failedRefund failed (e.g. insufficient merchant balance or provider rejection)
refund.erroredRefund encountered an error
refund.cancelledRefund was cancelled

Payout event types:

Event TypeDescription
payout.completedPayout was successfully delivered to the recipient
payout.failedPayout failed (e.g. invalid recipient account)
payout.erroredPayout encountered a retryable error
payout.cancelledPayout was cancelled
payout.expiredPayout expired

Payout beneficiary event types:

Event TypeDescription
payout_beneficiary.validatedBeneficiary passed validation and screening and is ready to be paid out to
payout_beneficiary.partial_matchBeneficiary returned a partial account-name match and is awaiting your confirmation
payout_beneficiary.screening_reviewBeneficiary requires manual compliance review before it can be used
payout_beneficiary.rejectedBeneficiary was rejected, either by validation or by screening

Rate lock event types:

Event TypeDescription
rate_lock.expiredA rate lock reached its expiresAt — fetch a new lock to keep transacting
rate_lock.cancelledA 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 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:

BodyEvents
Transactiontransaction.completed, .failed, .errored, .cancelled, .expired, .settled, .refunded, and every refund.* and payout.* event
Late payment summarytransaction.held_pending_review, .auto_refunded, .auto_refund_failed
Release summarytransaction.released
Beneficiaryevery 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.

FieldTypePresenceDescription
transactionIdstringAlwaysIdentifier of this transaction
statusstringAlwaysCurrent 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
messagestringAlwaysStatus message. Only meaningful on success; see the note below
merchantReferencestringOptionalYour reference from the original session
sessionIdstring (uuid)OptionalSession the transaction belongs to
paymentMethodIdstringOptionalPayment method used
identifiersobjectOptionalUsually sessionId and payerId (see Payer ID)
paymentAttributesobjectOptionalThe 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
processorNamestringOptionalDisplay name of the payment processor
processorReferencestring | nullNullableProcessor’s reference. null until the processor returns one
financialTransactionReferencestring | nullNullableProcessor’s financial reference. null when it supplies none
currentAttemptIdstringOptionalIdentifier of the current attempt
authStateobjectOptionalDetail about the current state — see below

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.

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:

statusauthState.stateAlso carries
COMPLETEDcompletedmessage
FAILEDfailedmessage, code, connectorFailureCode, connectorFailureMessage
ERROREDerrormessage, code, connectorFailureCode, connectorFailureMessage
CANCELLEDcancellednothing further
EXPIREDexpirednothing further
SETTLEDsettlednothing further
REFUNDEDauthState 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.
  • 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 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 if you need the rest.

FieldTypePresenceDescription
transactionIdstringAlwaysThe payment that arrived late
reasonstringAlwaysWhy 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.

FieldTypePresenceDescription
transactionIdstringAlwaysThe transaction that was released

Beneficiary

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

FieldTypePresenceDescription
payoutBeneficiaryIdstringAlwaysIdentifier of the beneficiary
merchantIdstringAlwaysYour merchant identifier
statusstringAlwaysPAYOUT_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
typestringAlwaysPAYOUT_BENEFICIARY_TYPE_ followed by BANK_ACCOUNT, MOBILE_MONEY, CASH, or INSTITUTION_WALLET
countrystringAlwaysBeneficiary’s country
currencystringAlwaysCurrency the beneficiary is paid in
validationobjectAlwaysAccount-name check: verified, state (PENDING, VERIFIED, PARTIAL_MATCH, or NOT_VERIFIED), attempts, and, when available, validatedAccountHolderName, returnedAccountHolderName, and lastError
amlScreeningobjectAlwaysScreening result: state (AML_SCREENING_STATE_ followed by PENDING, CLEARED, REVIEW, REJECTED, or ERROR), attempts, and, when available, screeningId, lastError, and reviewReason
updatedAtstringAlwaysWhen 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.)

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

{
"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):

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

{
"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, 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:

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

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

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

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