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 during compliance screening, before the customer is asked to sign — 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 (intercompany payouts, which use a whitelist recipient, and by default prefunding payouts) skips the gate. Because document review is part of the pre-signing compliance stage, the payout reads as status: "pending" while parked here — it flips to "processing" only once compliance + Travel Rule clear (transaction.processing). Travel Rule counterparty-webhook autopilot fires 10 s after the Travel Rule transfer is created on suffixes that encode a counterparty outcome — and since screening now runs before signing, that transfer is created once compliance + document review clear, not at payout creation — 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 with422 WALLET_NO_PROVIDER_ACCOUNT. 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. The ordering differs by custody model:- Custodial: validate → reserve → compliance screen → travel-rule resolve → (document review if documents attached) → broadcast → await finality → settle.
- Non-custodial: validate → reserve → compliance screen → travel-rule resolve → (document review if documents attached) → queue → collect the customer’s signatures → final co-sign → broadcast → await finality → settle. The full compliance screen and Travel Rule run before the customer is asked to sign, so screening happens before any signature is requested — the customer never signs a payment that then fails screening. A compliance rejection does not stop the payout automatically: it is held for review and then either released to proceed or confirmed as terminal (rejected or frozen); on a confirmed rejection the payout never broadcasts.
Full happy path: non-custodial wallet (single-signer cosign)
This flow covers a non-custodial wallet with a signing threshold of 1: its roster still has the required minimum of two admins, but a single signer’s stamp clears each payout. Provisioned viaPOST /v2/customers/:id/wallets/claim-non-custodial. For higher M-of-N thresholds, use the multi-signer flow below — claim-non-custodial is the only new-customer entry point either way.
Once the wallet is active, every payout from it is screened for compliance and Travel Rule first; only once it clears does the payout pause to collect the customer’s signatures before it is finalized. In production a roster signer approves on the Conduit-hosted verify page; in sandbox you resolve the gate via a simulate endpoint.
Step 1 — Create the wallet
Once the customer has claimed non-custodial control viaPOST /v2/customers/:customerId/wallets/claim-non-custodial and the single roster member has enrolled (in sandbox, POST /v2/sandbox/wallet-signers/{signerId}/mark-enrolled), 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 customer has claimed non-custodial control, the same call returns 422 WALLET_NO_PROVIDER_ACCOUNT.
Step 2 — Fund the wallet
UsePOST /v2/sandbox/customers/{customerId}/wallets/{walletId}/deposits/simulate with { "assetAmount": { "code": "USDC", "chain": "ethereum", "amount": "100" } }. Returns 202 Accepted with { chain, txHash }; 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:
queuePosition is not in the create response. When the payout is queued behind earlier signing work on the same wallet+chain, it is populated as the payout enters the queue gate (just after the 202) and is read from GET /v2/payouts/:id, where it is a positive integer (1 is next in line). The head (active) payout — the one currently being signed — omits the field entirely.
transaction.awaiting_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. This applies to a wallet in the passkey signing mode; a wallet in a programmatic signing mode instead carries a signingRequestId on that webhook — see Machine-signer stamping.
Step 4 — Approve the document review
Because the payout carriesdocuments, it parks for document review during compliance screening — before the customer is ever asked to sign. Clear it first via the sandbox simulate endpoint:
200 OK. Call simulate-review-reject instead to terminate the payout as failed with failureCode: compliance_rejected (reserved funds returned). Once compliance and Travel Rule clear, transaction.processing fires (status becomes processing) and the payout parks at the cosign gate — transaction.awaiting_signature fires with the verificationUrl.
Step 5 — 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 and return 200. Calling this endpoint against a payout that has already reached a terminal state (completed or failed) returns 409 RESOURCE_TERMINAL. After approved, the payout reaches status: completed automatically within about 5 seconds (chain-confirm autopilot); no follow-up simulate/confirm is required (the endpoint stays available to drive a custom txHash or the outcome: "failed" finality path).
Force-fail frees the wallet immediately. Force-failing a payout parked at the cosign gate (via
POST /v2/sandbox/transactions/:id/simulate/terminal { outcome: "failed" }) frees the wallet at once: a follow-up payout on the same wallet is accepted within milliseconds, with no cooldown.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 202 Accepted with { chain, txHash }; 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. Because this payout carries documents, it first parks for document review during the pre-signing compliance stage (Step 5). Only once that clears (and Travel Rule passes) does transaction.processing fire and the payout park at the quorum gate, at which point transaction.awaiting_signature fires with verificationUrl and expiresAt.
Step 5 — Approve the document review
This payout carriesdocuments, so it parks for document review during the pre-signing compliance stage — before the signers are asked to stamp. Clear it first:
200 OK. Call simulate-review-reject instead to terminate the payout as failed with failureCode: compliance_rejected (reserved funds returned). After approval, compliance + Travel Rule finish, transaction.processing fires, and the quorum gate opens.
Step 6 — Collect stamps to quorum
Each stamp is onePOST /v2/sandbox/payouts/{payoutId}/simulate-stamp call carrying the walletSignerId and an outcome (approved or declined, defaulting to approved when omitted). The response is the payout in its current state; quorum progress comes from the transaction.signature_collected webhook, whose payload carries the running collected and required counts:
transaction.signature_collected. Once collected reaches required, transaction.quorum_met fires and the payout proceeds to broadcast.
Rejection. A single declined outcome terminalizes the quorum 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 does not double-count, and transaction.signature_collected reports the same collected 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.
Once collected reaches required, transaction.quorum_met fires and 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 and, by default, purpose: prefunding must include at least one documents entry (documentation policy can still require documents on a prefunding payout above a configured amount — handle 422 DOCUMENTATION_REQUIRED there too). 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.
Accounts not matching any documented suffix take the happy path (
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 compliance mock resolves the address to a rejected decision, and the payout is held for compliance review — it keeps status: "pending", not an automatic failure. Resolve it with POST /v2/sandbox/transactions/:id/simulate/compliance-decision: { "outcome": "reject" } terminates it as failed with COMPLIANCE_REVIEW_REJECTED (no on-chain broadcast). Once compliance has rejected the payout, { "outcome": "approve" } is not available — a rejected case can only be terminalized via reject. 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.
transaction.failed payloads carry a failureCode when the cause is something your integration can act on — for example USER_SIGNATURE_TIMEOUT, USER_SIGNATURE_DECLINED, COMPLIANCE_REVIEW_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.