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/confirmdrives finality. - Fiat withdrawal: bank-recipient payout,
payouts/:id/simulate/settleddrives settlement.
- 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
- Custodial
- Non-custodial
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
ACTIVEcustomer (the onboarding flow leaves the customer in this state). SetSANDBOX_API_KEYin your shell. - The customer must have an
ACTIVEcrypto wallet for the asset and chain you want to test. New customers provision non-custodial wallets viaPOST /v2/customers/:id/wallets/claim-non-custodial; without that claim,POST /v2/customers/:id/walletsrejects with409 WALLET_CUSTODY_NOT_CLAIMED. Custody is fixed at provisioning time and cannot be flipped later. - For fiat withdrawals: the customer must have an
ACTIVEvirtual account with a sufficient USD balance.
- Base URL:
https://api.sandbox.conduit.financial.
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:| Step | Production | Sandbox |
|---|---|---|
| AML screen | Real upstream call | Mock; outcome from destination.address suffix |
| Travel Rule create | Real upstream call (VASP destinations) | Mock; row persisted iff destination suffix matches a VASP scenario |
| Cosign (non-custodial) | Customer passkey on verify page | payouts/: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 analyst | payouts/: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 broadcast | Mainnet | Mocked; deterministic synthetic txHash |
| Counterparty webhook | Real upstream delivery | Counterparty-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 finality | Real chain confirmation | Auto-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 settlement | Real rail settlement | POST /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 legacyWALLET_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.
Step 1 — Create the wallet
Once theWALLET_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
UsePOST /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 fullPOST /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:
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.
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.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: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 viaPOST /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
CallPOST /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 theverificationUrl 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 viaPOST /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
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 onePOST /v2/sandbox/payouts/{payoutId}/simulate-stamp call carrying the walletSignerId and selection (APPROVED or REJECTED). The response carries votesCollected and votesRequired:
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 carriesdocuments, 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 andsimulate/settled drives the terminal outcome.
Step 1 — Create the fiat payout
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 carriesdocuments parks for document review before it is submitted to the rail, exactly like the crypto flow. Approve it to let settlement proceed:
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
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:
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 ofrecipient.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 digits | Scenario | failureCode | Webhook |
|---|---|---|---|
94000000 | Happy path — settlement completes | — | transaction.completed |
94009001 | Rail policy rejects (amount limit, frequency cap, or recipient restriction) | RAIL_POLICY_REJECTED | transaction.failed |
94009002 | Insufficient funds at settlement (funds were available at reservation) | INSUFFICIENT_FUNDS_AT_SETTLE | transaction.failed |
94009003 | No viable rail available for the corridor | RAIL_UNAVAILABLE | transaction.failed |
94009004 | Rail 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_UNAVAILABLE | transaction.failed |
94000000 behavior).
Two separate mechanisms drive fiat settlement outcomes:
- Account-number suffix (
94009001–94009004): locks the outcome at payout-creation time. Once the payout reaches the settlement step the mock fires automatically — you do not need to callsimulate/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).
simulate/settled { outcome: "completed", utr: "..." } to supply the bank reference yourself.Failure paths
Fiat payouts can also be failed withPOST /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 carriesdocuments parks for document review. Call POST /v2/sandbox/payouts/:id/simulate-review-reject (no body) to reject it:
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 (suffix5A50AB1E) 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, callsimulate/confirm with outcome: "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.
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 — seePOST /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
failedon fiat or custodial-crypto in sandbox, use the failure-suffix protocol (see Failure paths) orPOST /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 orPOST /v2/webhooks/endpoints exactly like production.
| Event | When it fires |
|---|---|
transaction.created | Immediately after POST /v2/payouts is accepted |
transaction.awaiting_user_signature | Payout parks at the cosign gate (non-custodial only) |
transaction.awaiting_sender_information | Travel Rule path needs sender info before the counterparty leg can resolve |
transaction.completed | Payout reaches terminal completed state |
transaction.failed | Payout reaches terminal failed state (compliance reject, Travel Rule reject, cosign decline / timeout, broadcast fail, fiat rail failure) |
transaction.rejected | Payout 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.cancelled | Payout 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...DEADBEEFmatches suffixDEADBEEF. - 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.
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 fromscenario-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.
| Suffix | Class | Outcome | Auto-pilot? |
|---|---|---|---|
5A4D4EAA | AML approved | Happy path; proceeds to broadcast. | n/a |
5A4D4EE5 | AML rejected | transaction.failed with AML_REJECTED. | n/a |
5A4D4E5A | AML sanctions match | transaction.failed with AML_REJECTED (sanctions classification recorded for audit). | n/a |
5A4D4E51 | AML elevated risk | Routes approved at current threshold; no observable difference. | n/a |
5A50AB1E | VASP wallet | Opens Travel Rule row at SENT; counterparty leg must be driven manually. | No |
5E1F0577 | Self-hosted wallet | No Travel Rule transfer; proceeds to broadcast. | n/a |
12517C00 | Elevated risk (wallet screening) | Self-hosted resolution; the payout proceeds. | n/a |
5A4070ED | Sanctions match (wallet screening) | Payout terminates with AML_REJECTED (sanctions classification recorded for audit). | n/a |
AC6BC0DE | Counterparty ACK | Informational ACK; the payout proceeds. | Yes, 10 s after create. |
ACCEEDED | Counterparty accepted | Counterparty approves; the payout proceeds. | Yes, 10 s. |
BAD6A1A4 | Counterparty rejected | Counterparty rejects (pre-broadcast on non-custodial / post-broadcast on custodial). | Yes, 10 s. |
DEC11A1D | Counterparty declined | Counterparty declines (same pre/post-broadcast behavior as rejected). | Yes, 10 s. |
BAD7E517 | Travel Rule validation rejection | Unconditional pre-broadcast Travel Rule reject. | No (deterministic at /tx/validate). |
D15CCAD0 | Travel Rule discrepancy | Customer attested self-custody but wallet screening identifies a VASP; payout fails with TRAVEL_RULE_REJECTED. | No |
503CA110 | Travel Rule provider unavailable | Retryable Travel Rule provider failure; retries exhaust; payout parks in status: processing. | No |
5A4ED0DD | Travel Rule non-sendable after create | Travel Rule row persisted in non-sendable state after /tx/create; pre-broadcast fail with TRAVEL_RULE_REJECTED. | No |
DA171465 | Travel Rule waiting for information | Row starts at WAITING_FOR_INFORMATION; broadcast proceeds; row advances to ACCEPTED via auto-pilot post-broadcast. | Yes, 10 s. |
BAD8CA57 | Chain broadcast failure | Pre-broadcast chain provider rejection; reserved balance released; failureCode: PROVIDER_REJECTED. | n/a |
Fiat suffix (last 8 digits of recipient.accountNumber) | Outcome |
|---|---|
94000000 | Happy path; settlement completes. |
94009001 | RAIL_POLICY_REJECTED. |
94009002 | INSUFFICIENT_FUNDS_AT_SETTLE. |
94009003 | RAIL_UNAVAILABLE. |
94009004 | RAIL_UNAVAILABLE (provider timeout; same public code as 94009003). |