simulate/* endpoints. Crypto broadcast and signing are real. See Sandbox overview for the full posture.
This page walks the full lifecycle of both withdrawal types end-to-end:
- Crypto withdrawal: real testnet broadcast, signed by the customer’s roster; finality takes minutes.
- Fiat withdrawal: bank-recipient payout. It settles on its own unless the account number asks for a specific outcome — see the fiat account-number suffix catalog.
Crypto withdrawal on real testnet
A non-custodial crypto payout onPOST /v2/payouts behaves like production:
- It broadcasts for real. The payout broadcasts from the wallet on its own testnet (Sepolia, Base Sepolia, Amoy, Solana Devnet), and
transaction.completedcarries a realtxHashyou can open on that network’s block explorer. - It needs a real signature. The payout parks at
transaction.awaiting_signature. Apasskey_requiredwallet approves through the webhook’sverificationUrl; a programmatic wallet carries asigningRequestIdyour backend stamps atPOST /v2/signing-requests/:id/approve(Machine-signer stamping) — there is no simulate shortcut. - Fund the source wallet from a faucet. Send real test tokens to the wallet address, then withdraw. See Use a real testnet for faucets, finality thresholds, and explorers.
- It takes minutes to reach finality. Wait for
transaction.completed.
Withdrawal state machine
A payout parks for review — a missing or not-yet-approved document, a compliance hold, or both — before any signer is asked to approve. It readsstage: "under_review", or stage: "awaiting_customer_action" with hasRfi: true when Conduit is asking you for something. transaction.under_review fires once per review period. In sandbox, release it with POST /v2/sandbox/payouts/:id/simulate-review-approve, or end it with simulate-review-reject — which fails the payout with failureCode: compliance_rejected and returns the reserved funds. A payout the documentation policy requires no document for (intercompany and personal_transfer_of_funds payouts, which use a whitelist recipient, and by default prefunding payouts) skips this gate.
Once compliance and document review clear, transaction.processing fires and the payout enters processing. For a crypto payout to a VASP-attributed destination, Conduit resolves Travel Rule here — after processing starts, before any signature is requested. See Travel Rule scenarios.
A non-custodial crypto payout then parks for the customer’s signature: transaction.awaiting_signature carries the signing details, and on a multi-signer wallet transaction.signature_collected tracks quorum progress until transaction.quorum_met fires. The payout broadcasts on the testnet and reaches completed at real chain finality (minutes).
A fiat payout submits to the mocked rail during processing. An account number matching no suffix in the fiat account-number suffix catalog settles on its own; a 94009004 suffix leaves it reserved until POST /v2/sandbox/payouts/:id/simulate/settled supplies the bank’s final answer.
POST /v2/sandbox/transactions/:id/simulate/terminal { outcome: "failed" } can also force an accepted fiat payout to failed once the document-review gate is clear; outcome: "completed" requires a settlement-ready payout. This endpoint also supports onramp, offramp, and deposit transactions.
Prerequisites
- Your organization’s sandbox API key, and 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(no custody yet to create the wallet against). - 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 YOUR_API_KEY, {{customerId}}, {{walletId}}, and {{payoutId}} placeholders. Substitute the values from your sandbox setup.
Lifecycle
A sandbox payout walks the same lifecycle as production: validate → reserve → compliance and document review (if your policy requires one) → processing → Travel Rule resolve (crypto only) → collect the customer’s signature → broadcast → await finality → settle (fiat). Compliance and document review 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. The lifecycle is identical to production; only the decisions differ:Full happy path: non-custodial crypto withdrawal
A non-custodial wallet’ssigningThreshold decides the shape of this walkthrough: 1 gives a single-signer wallet, where one signer’s stamp clears each payout; a higher M-of-N threshold gives a multi-signer wallet, where each signer stamps independently and the payout auto-broadcasts once the threshold is met. Every step below is the same either way, except where noted. 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 a signingThreshold. The endpoint returns 202 with a claimId and dispatches one wallet_signer.invited webhook per roster member.
Step 2 — Enroll each signer and read the wallet
Each signer opens theverificationUrl from their wallet_signer.invited payload and enrolls a passkey on their device, in sandbox as in production (a machine roster needs no enrollment). Once the final signer enrolls, crypto_wallet.completed fires and the wallet flips active. The claim already made a wallet on each chain you named; read them with GET /v2/customers/:customerId/wallets and capture the id of the one you want to test as {{walletId}}. To add a wallet on a chain outside the claim (for example solana), call POST /v2/customers/:customerId/wallets with { "chain": "solana" }.
Step 3 — Fund the wallet
Send real testnet funds from a faucet (fund a wallet from a faucet) to the wallet address. A faucet sends from an address you have not registered, so the deposit parks awaiting sender information — sendPOST /v2/transactions/:id/sender-information with register: true the first time; later deposits from that same address pass straight through. Wait for transaction.completed on the deposit before creating the payout.
Step 4 — Create the payout
202 Accepted with { "id": "txn_...", "status": "pending", "requiresUserSignature": true, ... }. Capture the id as {{payoutId}}. queuePosition is not in the create response — see the queue note above if the wallet+chain is already signing another payout. The documents array must contain the id from a prior POST /v2/documents upload — a payout without one is either refused with 422 DOCUMENTATION_REQUIRED, or accepted and parked for review at stage: "awaiting_customer_action" with hasRfi: true (an organization with contacts configured for requests for information; see Send a payout). Because the documentation policy requires a document for this purpose, the payout first parks for document review (Step 5). Only once that clears does transaction.processing fire; Conduit then resolves Travel Rule for a VASP-attributed destination before the payout parks for the customer’s signature, at which point transaction.awaiting_signature fires with verificationUrl and expiresAt — carrying a signingRequestId instead for a programmatic wallet (Machine-signer stamping).
Step 5 — Approve the document review
Clear it first:200 OK; if the review has not cleared by the time the call returns, you get 503 SERVICE_UNAVAILABLE — the release still stands, so retry the call or read GET /v2/payouts/:id. Call simulate-review-reject instead to terminate the payout as failed with failureCode: compliance_rejected (reserved funds returned).
Step 6 — Approve the payout
A single-signer wallet (signingThreshold: 1) needs one approval: a signer opens the verificationUrl from transaction.awaiting_signature and approves with their passkey, or a programmatic wallet stamps the signingRequestId at POST /v2/signing-requests/:id/approve instead (Machine-signer stamping). The payout then broadcasts on the testnet and reaches completed at real chain finality (minutes). A decline fails the payout with failureCode: "user_signature_declined".
A multi-signer wallet needs quorum instead: each signer opens the verificationUrl on the device where they enrolled and approves independently. transaction.signature_collected carries the running collected and required counts; once collected reaches required, transaction.quorum_met fires and the payout broadcasts the same way.
Rejection. A single decline terminalizes a multi-signer quorum and fails the payout with failureCode: "user_signature_declined". The remaining signers cannot un-reject.
Re-stamping. A second approval by the same signer does not double-count, and transaction.signature_collected reports the same collected count.
Signer removed mid-payout. If a signer is removed from the roster while a payout is collecting stamps (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; a payout that can no longer reach quorum on the new roster terminates with failureCode: "roster_changed". See Signer removed mid-payout for the walkthrough.
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 the recipient’s account number selects the outcome (see the catalog below).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 documentation policy requires a document for this purpose, the payout then parks for document review (status: "pending", stage: "under_review") before settlement — clear the gate in the next step.
purpose and documents are required. Every payout except purpose: intercompany, purpose: personal_transfer_of_funds and, by default, purpose: prefunding needs at least one documents entry to take this happy path (documentation policy can still require documents on a prefunding payout above a configured amount). When the payout requirements return documentation.due: post_acceptance, Conduit accepts the payout without one and asks for the document at the review gate. Upload a supporting document first (POST /v2/documents) and pass its id in the documents array. intercompany and personal_transfer_of_funds payouts use a registered whitelist recipient instead of a document — see Whitelist Recipients. A prefunding payout must also be sent from a house account — a Conduit-issued account you fund your own customers’ payouts from (see Send a payout): your sandbox organization is seeded with one, or mark a BUSINESS customer you onboarded yourself with PATCH /v2/sandbox/customers/{customerId}/house-account.Step 2 — Approve the document review (sandbox only)
A fiat payout the documentation policy requires a document for 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; if the review has not cleared by the time the call returns, you get 503 SERVICE_UNAVAILABLE — the release still stands, so retry the call or read GET /v2/payouts/:id. 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 (only when the bank’s answer is still pending)
A recipient account number matching no suffix below — including the happy-path example from Step 1 — settles on its own at submission: wait fortransaction.completed, no extra call needed. simulate/settled exists for the one scenario where the bank’s answer is still pending: an account number ending in 94009004 leaves the payout reserved until you supply the bank’s final answer.
200 OK returning the payout at its current state. The outcome field is "completed" or "failed". utr is optional on a "completed" outcome — when supplied, it must be 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). When the sandbox bank already settled the wire on its own, the payout keeps the bank’s reference, and this call’s utr and reason are ignored.
To drive a failure instead, use outcome: "failed" with an optional reason string — reason becomes free-form text on the failure, never a failureCode:
transaction.failed fires with rail_unavailable — every simulate/settled failure carries this code; reason only becomes the human-readable message. To get a specific failure code instead, drive it through the account-number suffix catalog below — for example, an account ending in 94009002 fails at settlement with insufficient_funds_at_settle, with no call to this endpoint needed.
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. Choose an accountNumber whose tail matches the desired scenario when you build the payout’s destination.recipient.
Accounts not matching any documented suffix take the happy path (
94000000 behavior).
Routing-number directory
On a US rail (fedwire, rtp, fednow, ach, ach_same_day), destination.recipient.bankAddress is optional. Omit it and Conduit derives the recipient bank’s name and address from routingNumber.
In sandbox every routing number resolves to Simulated National Bank, except 999999992, which the directory does not hold. An unheld routing number returns 400 VALIDATION_ERROR with pointer /destination/recipient/bankAddress, exactly as production does for a routing number the real directory does not hold. When the directory cannot answer at all, the payout returns 503 RECIPIENT_BANK_LOOKUP_UNAVAILABLE, which is retryable and says nothing about the routing number you sent.
A request that supplies both bankAddress and bankName never consults the directory. Supplying bankAddress alone still consults it to fill the missing bankName, and the payout still goes through when the directory cannot answer, because bankName is optional. A bankName you send is never overwritten.
Suffixes
94009001–94009003 produce a bank rejection at settlement with no simulate/settled call needed (see the catalog above). 94009004 is the exception: it leaves the payout reserved, because a timeout does not prove the bank rejected the transfer, and only simulate/settled can supply the bank’s final answer. The payout reaches a terminal state only after that answer arrives; calling a simulation endpoint after settlement preserves the recorded outcome.Failure paths
Fiat payouts can also be failed withPOST /v2/sandbox/transactions/:id/simulate/terminal with { "outcome": "failed" } after payout acceptance, once the document-review gate is clear. While that gate is open the call returns 422 SANDBOX_TRANSACTION_NOT_FORCE_TERMINAL_READY. A payout in an in-progress phase with no transfer reference the simulation can drive yet returns the same 422 for either outcome; retry the call, or read the payout’s status and stage with GET /v2/payouts/:id if it keeps refusing. 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: consolidated withdrawal address-suffix catalog.
Document-review reject
A payout the documentation policy requires a document for parks for document review. CallPOST /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. Calling it again after the payout is already terminal is safe: it returns 200 OK with the payout at its current state, unchanged. 404 PAYOUT_NOT_FOUND applies only when the payout was never a withdrawal belonging to your organization, or was never parked for a review this call can resolve. 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" }. Before broadcast, rejected and declined terminate the payout as status: "failed" with failureCode: travel_rule_rejected. Once the payout has broadcast, the same call is audit-only: the on-chain transfer cannot be reversed, so the payout completes. Full catalog (including pre-broadcast Travel Rule failures): Travel Rule scenarios.
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 wallet’s signing gate. A fiat payout hands off to the rail inline once compliance clears (same as production), so its cancel window is a narrow race you usually can’t observe from a test.
- There is no “force-cancel” simulate endpoint. To drive
failedon fiat 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. See the Webhooks reference for the full payout event list, payload schemas, and the failureMessage symmetry contract covering the exact rules across the transaction record, GET, and the webhook payload.
Address-suffix matching rules
- EVM (Ethereum / Base / Polygon): last 8 hex characters of the address, case-insensitive.
0x...DEADBEEFmatches suffixDEADBEEF. - 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, straight to broadcast.