Skip to main content
The sandbox cluster is fully mocked. There is no real chain, no real signing, no third-party vendor calls. Every outcome is deterministic and driven by your request data (destination-address suffixes or bank-account suffixes) or by sandbox-only simulate/* endpoints. The synthetic txHash you receive is shaped like a real one but does not appear on any explorer. See Sandbox overview for the full posture. This page walks the full lifecycle of both withdrawal types end-to-end:
  • Crypto withdrawal: mocked chain broadcast, payouts/:id/simulate/confirm drives finality.
  • Fiat withdrawal: bank-recipient payout, payouts/:id/simulate/settled drives settlement.
For crypto withdrawals, two custody flavors are covered:
  • Custodial: Conduit signs and broadcasts on the customer’s behalf.
  • Non-custodial: the customer cosigns each outbound transfer; sandbox bypasses the real passkey UI via simulate endpoints.

Withdrawal state machine

Annotations: a payout that carries documents parks for document review after compliance and cosign clear — call payouts/:id/simulate-review-approve to resume it, or simulate-review-reject to terminate it as failed with failureCode: COMPLIANCE_REJECTED (the reserved funds return to your available balance). A payout with no documents (only INTERCOMPANY payouts, which use a whitelist recipient) skips the gate. The payout reads as status: "processing" while parked. Compliance screening runs in pending; Travel Rule counterparty-webhook autopilot fires 10 s after payout creation on suffixes that encode a counterparty outcome — see Travel Rule scenarios; custodial payouts drive broadcasting → completed via payouts/:id/simulate/confirm; non-custodial payouts auto-advance about 5 s after cosign approved via the chain-finality autopilot (distinct timer from the counterparty-webhook autopilot). Fiat payouts use payouts/:id/simulate/settled to terminalize. Transaction-level transactions/:id/simulate/terminal { outcome: "failed" } can force accepted fiat payouts to failed earlier; outcome: "completed" requires settlement-ready state. The transaction-level endpoint supports withdrawal, onramp, offramp, and deposit transaction types.

Prerequisites

  • A sandbox API key for an ACTIVE customer (the onboarding flow leaves the customer in this state). Set SANDBOX_API_KEY in your shell.
  • The customer must have an ACTIVE crypto wallet for the asset and chain you want to test. New customers provision non-custodial wallets via POST /v2/customers/:id/wallets/claim-non-custodial; without that claim, POST /v2/customers/:id/wallets rejects with 409 WALLET_CUSTODY_NOT_CLAIMED. Custody is fixed at provisioning time and cannot be flipped later.
  • For fiat withdrawals: the customer must have an ACTIVE virtual account with a sufficient USD balance.
Legacy custodial customers convert through WALLET_CUSTODY_CONVERSION. Customers that were provisioned through the legacy CRYPTO_WALLET application before the non-custodial gate get 409 CUSTOMER_ALREADY_CUSTODIAL if they call claim-non-custodial. Submit POST /v2/customers/:id/features with { "type": "WALLET_CUSTODY_CONVERSION" } instead. Fresh KYB-approved customers go straight to claim-non-custodial.
  • Base URL: https://api.sandbox.conduit.financial.
All curl examples below use {{apiKey}}, {{customerId}}, {{walletId}}, and {{payoutId}} placeholders. Substitute the values from your sandbox setup.

Lifecycle

A sandbox payout walks the same lifecycle as production: validate → reserve → AML screen → travel-rule resolve → (cosign for non-custodial) → (document review if documents attached) → broadcast → await finality → settle. The lifecycle is identical to production; only the decisions differ:
StepProductionSandbox
AML screenReal upstream callMock; outcome from destination.address suffix
Travel Rule createReal upstream call (VASP destinations)Mock; row persisted iff destination suffix matches a VASP scenario
Cosign (non-custodial)Customer passkey on verify pagepayouts/:id/simulate/cosign (the wallet-keyed wallets/:walletId/simulate-cosign-complete lever has been removed; cosign resolution is now payout-keyed)
Document review (payouts that carry documents)Conduit reviews the supporting documents before the payout proceeds; clear cases pass automatically, the rest are reviewed by a compliance analystpayouts/:id/simulate-review-approve to resume the payout, or payouts/:id/simulate-review-reject to terminate it as failed with failureCode: COMPLIANCE_REJECTED (reserved funds returned). There is no automatic pass in sandbox — you must call one of the two levers.
Chain broadcastMainnetMocked; deterministic synthetic txHash
Counterparty webhookReal upstream deliveryCounterparty-webhook autopilot fires 10s after payout creation when the destination suffix encodes a counterparty outcome (BAD6A1A4, DEC11A1D, ACCEEDED). The VASP-attributed suffix 5A50AB1E opens the Travel Rule gate but does not auto-resolve — call payouts/:id/simulate/counterparty-webhook manually. Distinct from the chain-confirm autopilot (5s after cosign approved).
Chain finalityReal chain confirmationAuto-resolves about 5s after the signing gate clears (chain-confirm autopilot); POST /v2/sandbox/payouts/:id/simulate/confirm to override (custom txHash) or simulate outcome: "failed"
Fiat settlementReal rail settlementPOST /v2/sandbox/payouts/:id/simulate/settled

Full happy path: non-custodial wallet (single-signer cosign)

This flow covers customers who reached non-custodial via the legacy WALLET_CUSTODY_CONVERSION application (one wallet owner, single passkey/OAuth cosign). For fresh customers, use the multi-signer flow below — POST /v2/customers/:id/wallets/claim-non-custodial is the only new-customer entry point. After conversion, every payout from the wallet pauses at a single-signer cosign gate after compliance and Travel Rule clear. In production the wallet owner approves on the Conduit-hosted verify page; in sandbox you resolve the gate via a simulate endpoint.
Only one payout per non-custodial wallet can sit at the cosign gate at a time. Submit serially or handle 409 CONCURRENT_COSIGN_IN_FLIGHT by retrying after the gate resolves. The lock releases automatically on terminal state (including via simulate/failed).

Step 1 — Create the wallet

Once the WALLET_CUSTODY_CONVERSION application reaches status: "approved" and the wallet owner has completed the cosign-key challenge, POST /v2/customers/:customerId/wallets with { "chain": "ETHEREUM" } returns 201 Created and a wallet object with id, address, chain, status: "active", and custodyModel: "non_custodial". Capture id as {{walletId}}. Before the conversion completes, the same call returns 409 WALLET_CUSTODY_NOT_CLAIMED.

Step 2 — Fund the wallet

Use POST /v2/sandbox/customers/{customerId}/wallets/{walletId}/deposits/simulate with { "assetAmount": { "code": "USDC", "chain": "ETHEREUM", "amount": "100" } }. Returns 201 Created; the wallet balance updates within a couple of seconds.

Step 3 — Create the payout

See the multi-signer flow below for the full POST /v2/payouts request body (USDC on ETHEREUM, crypto destination with attestation.custody: "self", plus documents and purpose) — the request shape is identical between the two flows. The response carries requiresUserSignature: true and (when the payout is queued behind earlier signing work on the same wallet+chain) a queuePosition:
{
  "id": "txn_...",
  "status": "pending",
  "requiresUserSignature": true,
  "queuePosition": 0
}
transaction.awaiting_user_signature fires when the payout parks at the cosign gate, and the webhook payload carries verificationUrl + expiresAt. Subscribe to that topic to receive the verify URL — it is not part of the GET response shape.
Concurrent cosign in-flight (409). POST /v2/payouts returns 409 with type: "CONCURRENT_COSIGN_IN_FLIGHT" when the source wallet already has a non-custodial payout awaiting signature on the same chain. Resolve the prior payout with simulate/cosign first, or wait for it to terminalize. Retry with exponential backoff starting at 2 seconds. See CONCURRENT_COSIGN_IN_FLIGHT playbook.

Step 4 — Resolve the cosign gate

The payout-keyed simulate endpoint drives the gate to a terminal cosign outcome. It produces the same effect as a real customer action on the verify page.
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/cosign \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "approved" }'
200 OK returning the payout at its current state. outcome is "approved" or "declined". Replays where the cosign gate already cleared but the payout is still active collapse to a no-op at the workflow layer and return 200. Calling this endpoint against a payout that has already reached a terminal state (completed or failed) returns 409 RESOURCE_TERMINAL. Because the payout carries documents, after approved it parks for document review rather than broadcasting straight away — clear the gate in the next step.
Cosign nonce-lock release on force-fail. Force-failing a payout parked at the cosign gate (via POST /v2/sandbox/transactions/:id/simulate/terminal { outcome: "failed" }) releases the wallet’s cosign lock immediately. A follow-up payout on the same wallet is accepted within milliseconds.

Step 5 — Approve the document review

The payout carries a supporting document, so it parks for document review after cosign clears. Approve it via the sandbox simulate endpoint:
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-approve \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
200 OK. After approval the payout reaches status: completed automatically within about 5 seconds (chain-confirm autopilot); no follow-up simulate/confirm is required. The simulate/confirm endpoint stays available if you want to drive a custom txHash or test the outcome: "failed" finality path. Call simulate-review-reject instead to terminate the payout as failed with failureCode: COMPLIANCE_REJECTED (reserved funds returned).

Full happy path: multi-signer non-custodial

When the wallet was provisioned via POST /v2/customers/{id}/wallets/claim-non-custodial, every payout pauses at an M-of-N quorum gate instead of a single-signer cosign gate. Each signer stamps independently; the payout auto-broadcasts when the threshold is met. For the mental model see Multi-signer wallets. For threshold rules see Signing thresholds. For the copy-paste walkthrough see Multi-signer wallets recipe.

Step 1 — Provision the roster

Call POST /v2/customers/{customerId}/wallets/claim-non-custodial with a roster (admins + signers, each with a unique email and credentialType: "passkey") and a signingThreshold. The endpoint returns 202 with a claimId and dispatches one wallet_signer.invited webhook per roster member.

Step 2 — Enroll each signer

In production, signers visit the verificationUrl from their wallet_signer.invited payload and enroll a passkey. In sandbox, call POST /v2/sandbox/wallet-signers/{signerId}/mark-enrolled for each signer. Once the final signer is enrolled, crypto_wallet.completed fires and the wallet flips ACTIVE.

Step 3 — Fund the wallet

Inject a synthetic deposit via POST /v2/sandbox/customers/{customerId}/wallets/{walletId}/deposits/simulate with { "assetAmount": { "code": "USDC", "chain": "ETHEREUM", "amount": "100" } }. Returns 201 Created; balance updates within a couple of seconds.

Step 4 — Create the payout

curl -X POST https://api.sandbox.conduit.financial/v2/payouts \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "{{customerId}}",
    "assetAmount": {
      "code": "USDC",
      "chain": "ethereum",
      "amount": "10.000000"
    },
    "destination": {
      "type": "crypto",
      "recipient": {
        "rail": "CRYPTO",
        "chain": "ETHEREUM",
        "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        "attestation": { "custody": "self" }
      }
    },
    "purpose": "TREASURY_MANAGEMENT",
    "documents": ["{{docId}}"]
  }'
202 Accepted with { "id": "txn_...", "status": "pending", "requiresUserSignature": true, ... }. Capture the id as {{payoutId}}. The documents array must contain the id from a prior POST /v2/documents upload — payouts without supporting documents are rejected at creation with 422 DOCUMENTATION_REQUIRED. Once the payout parks at the quorum gate, transaction.awaiting_user_signature fires with verificationUrl and expiresAt in the webhook payload.

Step 5 — Collect stamps to quorum

Each stamp is one POST /v2/sandbox/payouts/{payoutId}/simulate-stamp call carrying the walletSignerId and selection (APPROVED or REJECTED). The response carries votesCollected and votesRequired:
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-stamp \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json" \
  -d '{ "walletSignerId": "{{signerId}}", "selection": "APPROVED" }'
Each stamp also re-fires transaction.signature_collected. Once votesCollected reaches votesRequired, transaction.quorum_met fires and the payout proceeds to broadcast. Rejection. A single REJECTED selection terminalizes the quorum as DECLINED and fails the payout with failureCode: "USER_SIGNATURE_DECLINED". The remaining signers cannot un-reject. Re-stamping. Calling simulate-stamp twice with the same walletSignerId is idempotent: the second call returns the same votesCollected and does not double-count. Ghost-vote scrubbing. If a signer is removed from the roster mid-payout (via DELETE /v2/customers/:id/wallet-signers/:signerId), their already-cast stamps are scrubbed from every in-flight payout for the customer. Affected payouts re-fire transaction.signature_collected with the new count; payouts that can no longer reach quorum on the new roster terminate with failureCode: "ROSTER_CHANGED". See Ghost-vote scrubbing for the walkthrough.

Step 6 — (Optional) Approve the document review

If the payout carries documents, it parks for document review after quorum is met. Approve it the same way as the single-signer cosign flow. Otherwise the payout reaches completed within about 5 seconds (chain-confirm autopilot).

Fiat withdrawal

A fiat withdrawal moves funds from a customer’s virtual account to a bank account. The payout is submitted to the configured payment rail; in sandbox the rail call is mocked and simulate/settled drives the terminal outcome.

Step 1 — Create the fiat payout

curl -X POST https://api.sandbox.conduit.financial/v2/payouts \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "{{customerId}}",
    "virtualAccountId": "{{virtualAccountId}}",
    "assetAmount": { "code": "USD", "amount": "25.00" },
    "destination": {
      "type": "fiat",
      "rail": "FEDWIRE",
      "recipient": {
        "rail": "US",
        "type": "BUSINESS",
        "legalName": "Acme Corp",
        "accountNumber": "1234594000000",
        "routingNumber": "021000021",
        "accountType": "CHECKING",
        "bankAddress": {
          "addressLine1": "1 Bank Plaza",
          "city": "New York",
          "state": "NY",
          "postalCode": "10005",
          "country": "USA"
        },
        "phone": "+12125550100",
        "postalAddress": {
          "addressLine1": "10 Vendor St",
          "city": "New York",
          "state": "NY",
          "postalCode": "10005",
          "country": "USA"
        }
      }
    },
    "purpose": "TREASURY_MANAGEMENT",
    "documents": ["{{docId}}"]
  }'
202 Accepted with { "id": "txn_...", "status": "pending", ... }. Capture the id as {{payoutId}}. Compliance screening runs automatically; in sandbox it resolves based on the account-number suffix (see catalog below). The accountNumber 1234594000000 ends in the last 8 digits 94000000 which is the happy-path suffix. Because the payout carries documents, it then parks for document review (status: "processing") before settlement — clear the gate in the next step.
purpose and documents are required. Every payout except purpose: INTERCOMPANY must include at least one documents entry. Upload a supporting document first (POST /v2/documents) and pass its id in the documents array. INTERCOMPANY payouts use a registered whitelist recipient instead of a document — see Whitelist Recipients.

Step 2 — Approve the document review (sandbox only)

A fiat payout that carries documents parks for document review before it is submitted to the rail, exactly like the crypto flow. Approve it to let settlement proceed:
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-approve \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
200 OK returning the payout at its current state. Call simulate-review-reject instead to terminate the payout as failed with failureCode: COMPLIANCE_REJECTED; transaction.rejected fires and the reserved funds return to the virtual account.

Step 3 — Drive settlement

curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/settled \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "outcome": "completed",
    "utr": "IMAD-20260115-001"
  }'
200 OK returning the payout at its current state. The outcome field is "completed" or "failed". utr is required when outcome is "completed" — supply a non-empty bank reference (IMAD, UETR, ACH trace number, or instant-payment reference). The payout transitions to status: "completed", completedAt is populated, and transaction.completed fires carrying that reference on the destination’s typed wire-reference field for the rail (e.g. external_bank.fedwireImad, external_bank.swiftUetr). To drive a failure instead, use outcome: "failed" with an optional reason string:
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/settled \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "failed", "reason": "INSUFFICIENT_FUNDS" }'
transaction.failed fires with RAIL_UNAVAILABLE or the failure code from the rail mock.

Fiat account-number suffix catalog

The sandbox reads the last 8 digits of recipient.accountNumber (all non-digit characters stripped before matching) to select a deterministic outcome. Set the suffix at account-creation time by choosing an accountNumber whose tail matches the desired scenario.
Last 8 digitsScenariofailureCodeWebhook
94000000Happy path — settlement completestransaction.completed
94009001Rail policy rejects (amount limit, frequency cap, or recipient restriction)RAIL_POLICY_REJECTEDtransaction.failed
94009002Insufficient funds at settlement (funds were available at reservation)INSUFFICIENT_FUNDS_AT_SETTLEtransaction.failed
94009003No viable rail available for the corridorRAIL_UNAVAILABLEtransaction.failed
94009004Rail provider timeout (internal PROVIDER_TIMEOUT distinction preserved on internal logs; the public surface emits the same RAIL_UNAVAILABLE by design — integration retry semantics are identical for either cause)RAIL_UNAVAILABLEtransaction.failed
Accounts not matching any documented suffix take the happy path (94000000 behavior).
Two separate mechanisms drive fiat settlement outcomes:
  • Account-number suffix (9400900194009004): locks the outcome at payout-creation time. Once the payout reaches the settlement step the mock fires automatically — you do not need to call simulate/settled.
  • simulate/settled { outcome: "failed" }: forces a failure on demand for any payout, independent of the account-number suffix. Use this when you want to drive a failure without embedding a magic suffix in the account number (for example, when testing retry logic against an existing account).
For the happy path, use simulate/settled { outcome: "completed", utr: "..." } to supply the bank reference yourself.

Failure paths

Fiat payouts can also be failed with POST /v2/sandbox/transactions/:id/simulate/terminal with { "outcome": "failed" } after payout acceptance. The same endpoint with { "outcome": "completed", "utr": "..." } requires a settlement-ready payout; calling it before the API has selected a settlement route returns 422 SANDBOX_TRANSACTION_NOT_FORCE_TERMINAL_READY. Other primary sandbox failure paths:

Compliance reject

Send to a destination address whose last 8 characters match one of the compliance-reject suffixes (5A4D4EE5, 5A4D4E5A). The AML mock resolves the address to a non-CLEAR decision; the payout terminates at the compliance gate and transaction.failed fires with AML_REJECTED. No on-chain broadcast occurs. Full catalog: Withdrawal failure paths.

Document-review reject

A payout that carries documents parks for document review. Call POST /v2/sandbox/payouts/:id/simulate-review-reject (no body) to reject it:
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-reject \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
200 OK returning the payout at its current state. The payout terminates as status: "failed" with failureCode: COMPLIANCE_REJECTED, transaction.rejected fires (not transaction.failed), and the reserved funds return to the source balance. Returns 404 PAYOUT_NOT_FOUND if the payout is not awaiting document review. To re-attempt, upload an acceptable document and submit a new payout with a fresh idempotency-key.

Travel Rule paths

Use a VASP-attributed destination (suffix 5A50AB1E) to route the payout through the Travel Rule flow. Drive the counterparty leg either with auto-pilot suffixes (AC6BC0DE, ACCEEDED, BAD6A1A4, DEC11A1D) or pre-empt the 10-second timer by calling POST /v2/sandbox/payouts/:id/simulate/counterparty-webhook with { "outcome": "acknowledged" | "approved" | "rejected" | "declined" }. rejected and declined always terminate the payout as status: "failed" with failureCode: TRAVEL_RULE_REJECTED — sandbox handles the signal regardless of whether the broadcast has happened (deterministic outcome the dashboard can render). Full catalog (including pre-broadcast Travel Rule failures): Travel Rule scenarios.

Broadcast finality fail

To terminate from the post-broadcast side, call simulate/confirm with outcome: "failed":
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/confirm \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "failed", "reason": "chain broadcast failed" }'
200 OK returning the payout at its current state. The payout transitions to status: "failed"; transaction.failed fires. For the pre-broadcast variant (the payout rejects before any chain broadcast happens, no txHash is assigned, the reserved balance returns to available), send to a destination ending in suffix BAD8CA57. The full chain endpoint reference and suffix catalog is in Withdrawal failure paths + chain reference.

Simulate broadcast failure (pre-broadcast arm)

POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{payoutId}/simulate/broadcast-fail arms the mock chain provider so that the next broadcast attempt for this payout throws a pre-broadcast error. The handler does not directly fire transaction.failed; instead it parks the payout via the standard pre-broadcast compensation path. That compensation produces a transaction.failed event. No txHash is assigned and the reserved balance returns to available. Use this when you want to test the broadcast-failure path for a specific in-flight payout without relying on the address-suffix mechanism.
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/broadcast-fail \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Simulated broadcast rejection" }'
200 OK returning the payout at its current state. reason is the only accepted body field (optional, 1 to 500 chars). The body uses .strict() validation; any other key (including code) returns 400 VALIDATION_ERROR. Returns 404 PAYOUT_NOT_FOUND if the payout is not a crypto withdrawal or does not belong to your organization. The compensation path raises transaction.failed. The public webhook fires with failureCode: "PROVIDER_REJECTED" and the supplied reason on failureMessage (the sandbox scenario tag is stripped before publishing). The same code/message land on the polled GET /v2/transactions/:id response per the failureMessage symmetry contract. Live broadcasts can still surface failureCode: null when an unstructured chain-provider rejection lands; that’s the live-only path, not this sandbox lever.

Cancelling a payout

Sandbox uses the production cancel contract — see POST /v2/payouts/:id/cancel for state-based rules and error codes. Two sandbox-specific deltas:
  • The reliably scriptable cancel window is the non-custodial cosign gate; fiat and custodial-crypto payouts hand off inline (same as production), so their cancel window collapses to a narrow pre-handoff race that you usually can’t observe from a test.
  • There is no “force-cancel” simulate endpoint. To drive failed on fiat or custodial-crypto in sandbox, use the failure-suffix protocol (see Failure paths) or POST /v2/sandbox/transactions/:id/simulate/terminal { outcome: "failed" }.

Webhook events

Every webhook your production endpoint would receive also fires in sandbox, with synthesized data. Configure the sandbox endpoint via the dashboard or POST /v2/webhooks/endpoints exactly like production.
EventWhen it fires
transaction.createdImmediately after POST /v2/payouts is accepted
transaction.awaiting_user_signaturePayout parks at the cosign gate (non-custodial only)
transaction.awaiting_sender_informationTravel Rule path needs sender info before the counterparty leg can resolve
transaction.completedPayout reaches terminal completed state
transaction.failedPayout reaches terminal failed state (compliance reject, Travel Rule reject, cosign decline / timeout, broadcast fail, fiat rail failure)
transaction.rejectedPayout is rejected at the document-review gate (simulate-review-reject). Payload carries reasonCategory: "document_inadequate" and the accepted document types; reserved funds are returned. Polled GET /v2/payouts/:id shows status: "failed" with failureCode: COMPLIANCE_REJECTED.
transaction.cancelledPayout reaches terminal cancelled state via POST /v2/payouts/{id}/cancel. Payload carries cancellationReason: "client_cancelled" and cancelledAt; no failureCode/failureMessage.
transaction.failed payloads carry a failureCode when the cause is something your integration can act on — for example USER_SIGNATURE_TIMEOUT, USER_SIGNATURE_DECLINED, AML_REJECTED, INSUFFICIENT_FUNDS_AT_SETTLE, RAIL_POLICY_REJECTED, RAIL_UNAVAILABLE, TRAVEL_RULE_REJECTED. See the Webhooks reference for full payload schemas. See the failureMessage symmetry contract on the webhooks reference for the exact rules across the DB row, polled GET, and webhook payload.

Address-suffix matching rules

  • EVM (Ethereum / Base / Polygon): last 8 hex characters of the address, case-insensitive. 0x...DEADBEEF matches suffix DEADBEEF.
  • Tron: last 8 Base58 characters, case-sensitive.
  • Solana: last 8 Base58 characters, case-sensitive.
  • Fiat (bank account): last 8 digits of recipient.accountNumber, all non-digit characters stripped before matching.
The address must be valid for its chain; sandbox does not bypass on-chain address validation. Addresses not matching any documented suffix take the happy path (AML APPROVED, SELF_HOSTED Travel Rule resolution, proceed to mocked broadcast).

Consolidated withdrawal address-suffix catalog

Every suffix that is meaningful on a withdrawal destination, sourced from scenario-suffixes.ts. Suffixes match the last 8 characters of the destination address (lowercased hex for EVM; Base58 verbatim for Tron and Solana). For scenario-specific detail, see the consolidated withdrawal address-suffix catalog, the Wallet screening catalog, and the Counterparty-outcome catalog. The full programmatic list is the Scenario suffix table.
SuffixClassOutcomeAuto-pilot?
5A4D4EAAAML approvedHappy path; proceeds to broadcast.n/a
5A4D4EE5AML rejectedtransaction.failed with AML_REJECTED.n/a
5A4D4E5AAML sanctions matchtransaction.failed with AML_REJECTED (sanctions classification recorded for audit).n/a
5A4D4E51AML elevated riskRoutes approved at current threshold; no observable difference.n/a
5A50AB1EVASP walletOpens Travel Rule row at SENT; counterparty leg must be driven manually.No
5E1F0577Self-hosted walletNo Travel Rule transfer; proceeds to broadcast.n/a
12517C00Elevated risk (wallet screening)Self-hosted resolution; the payout proceeds.n/a
5A4070EDSanctions match (wallet screening)Payout terminates with AML_REJECTED (sanctions classification recorded for audit).n/a
AC6BC0DECounterparty ACKInformational ACK; the payout proceeds.Yes, 10 s after create.
ACCEEDEDCounterparty acceptedCounterparty approves; the payout proceeds.Yes, 10 s.
BAD6A1A4Counterparty rejectedCounterparty rejects (pre-broadcast on non-custodial / post-broadcast on custodial).Yes, 10 s.
DEC11A1DCounterparty declinedCounterparty declines (same pre/post-broadcast behavior as rejected).Yes, 10 s.
BAD7E517Travel Rule validation rejectionUnconditional pre-broadcast Travel Rule reject.No (deterministic at /tx/validate).
D15CCAD0Travel Rule discrepancyCustomer attested self-custody but wallet screening identifies a VASP; payout fails with TRAVEL_RULE_REJECTED.No
503CA110Travel Rule provider unavailableRetryable Travel Rule provider failure; retries exhaust; payout parks in status: processing.No
5A4ED0DDTravel Rule non-sendable after createTravel Rule row persisted in non-sendable state after /tx/create; pre-broadcast fail with TRAVEL_RULE_REJECTED.No
DA171465Travel Rule waiting for informationRow starts at WAITING_FOR_INFORMATION; broadcast proceeds; row advances to ACCEPTED via auto-pilot post-broadcast.Yes, 10 s.
BAD8CA57Chain broadcast failurePre-broadcast chain provider rejection; reserved balance released; failureCode: PROVIDER_REJECTED.n/a
Fiat suffix (last 8 digits of recipient.accountNumber)Outcome
94000000Happy path; settlement completes.
94009001RAIL_POLICY_REJECTED.
94009002INSUFFICIENT_FUNDS_AT_SETTLE.
94009003RAIL_UNAVAILABLE.
94009004RAIL_UNAVAILABLE (provider timeout; same public code as 94009003).

See also