Total time: about 10 minutes. This page walks the full chain from API key
to a
transaction.completed webhook. Every code sample uses the themed
example data palette; none of the values collide with
magic suffixes.Prerequisites
- A sandbox API key (
ck_sandbox_...). Find yours in the dashboard under API Keys. - A webhook endpoint URL. Use webhook.site as a free stand-in for a real endpoint.
- For the bash track:
curl,jq, anduuidgenon your PATH.
application.approved, virtual_account.activated, transaction.*) is delivered only to registered endpoints:
201 Created. Deliveries to this URL are HMAC-signed; see the Webhooks reference for signature verification.
How the flow works
Step 1 - Upload a KYB document
Onboarding requires at least one supporting business document. Upload a minimal PDF first and capture the returnedid for Step 2.
201 Created returns { "id": "doc_...", ... }. Capture the doc_... id as KYB_DOC_ID — Step 2 references it in documentIds.
Allowed file types: PDF, PNG, JPEG. Maximum size: 10 MB. The file content (not the filename or Content-Type) is what’s validated.
Step 2 - Onboard the customer
Submit a business onboarding application for Aurora Robotics Inc. with Aiko Tanaka as the beneficial owner. In sandbox the review pipeline is mocked; there is no real KYB call.Onboarding requirements are dynamic. Call
GET /v2/onboarding/requirements?country=USA first to fetch the live { fields[], documents[], minDocuments, individualRequirements[] }. minDocuments is the document floor — when it is 1, attach at least one document before submitting. The body below is one valid US shape, not a fixed contract. Sandbox and production use the same requirements.The requirements endpoint is authoritative for the industry field name and its accepted values — they are jurisdiction-specific, so read them from the response rather than hard-coding. For USA today it returns companyClassification.coreIndustry as an enum whose allowedValues are display strings ("Financial Technology", "Manufacturing", "Healthcare", "Professional Services", "Other", …); send one of those literal strings, not a slug. The sample below uses coreIndustry; always send the field the requirements endpoint currently lists for the country.202 Accepted with the application response ({ id: "app_...", status: "processing", type: "customer_onboarding", createdAt, updatedAt, submittedAt, ... }). Capture id as APP_ID. The application is in status: "processing" immediately and the review pipeline picks it up asynchronously. The customerId field is omitted until the application reaches approved.
Step 3 - Approve the application
Drive the application toapproved. The synchronous response returns the application ({ id: "app_...", status: "approved", ... }); the new customerId lands a moment later via the application.approved webhook, and on the next GET /v2/applications/{APP_ID}.
200 with the application object. Your webhook receives application.approved carrying customerId. The id is minted asynchronously, so poll the application until it appears:
CUSTOMER_ID.
You can skip this call entirely. The sandbox auto-approves customer onboarding
applications after one hour. Calling
simulate/decision is faster for
testing.Step 4 - Create a virtual account feature
Apply for a USD virtual account on the new customer.202 Accepted returns the application with status: "approved" immediately — feature applications with no extra review payload auto-approve on submission. Provisioning runs asynchronously; your webhook endpoint receives virtual_account.activated { virtualAccountId: vac_... }, and the list call shows the new VA status: "active" within a couple of seconds. Capture it as VAC_ID:
There is no separate
simulate/decision approval step for the
virtual_account feature. Submitting it without extra review fields
auto-approves it inline; calling simulate/decision afterwards returns 409 APPLICATION_ALREADY_DECIDED.Step 5 (optional crypto branch) - Provision a crypto wallet
Skip this step if you only want the fiat path. The rest of the quickstart (deposit → fiat payout) works without a wallet. New customers reach a usable wallet through a single non-custodial flow: claim non-custodial control → wait for the roster to enroll → the claimed wallets activate automatically. The claim provisions a wallet on the default set of supported chains (ethereum, base, polygon, tron), or just the ones you name in an optional chains array — there is no separate per-chain creation step. Calling POST /v2/customers/:id/wallets before the claim returns 422 WALLET_NO_PROVIDER_ACCOUNT; afterward you only call it to add a chain outside the default set (such as solana) or one you excluded from the claim.
Step 5.1 — Enable the CRYPTO_WALLET feature
Before claim-non-custodial, the customer must have an approvedCRYPTO_WALLET feature on file. Submit it first:
status: "approved" and you can claim immediately. In live the default is manual review (status: "processing") unless the org has opted out of the review gate; drive a pending application terminal in sandbox via POST /v2/sandbox/applications/:id/simulate/decision { outcome: "approved" }. Calling claim-non-custodial before the feature is approved returns 422 CRYPTO_FEATURE_NOT_APPROVED. Customers whose registered country is on Conduit’s crypto-restricted list get 422 CRYPTO_NOT_AVAILABLE_IN_JURISDICTION at this step (the claim never runs).
Step 5.2 — Claim non-custodial control
POST /v2/customers/:id/wallets/claim-non-custodial provisions the non-custodial wallet account and mints invitations for every roster member. Requires the customer to be KYB-approved AND have an active CRYPTO_WALLET feature row (see Step 5.1).
The DTO requires at least 2 roster members and 2 admins. The roster shape you send depends on the customer’s signing mode, so send the one that matches — the wrong shape is rejected at claim.
Sandbox default:
programmatic_unattended. A new sandbox organization resolves to the programmatic_unattended signing mode. That mode permits api_key admins, so the minimal machine roster is two api_key admins reaching the threshold — the machine-signer roster shown below (each with a compressed publicKey, signingThreshold: 2). The mode never limits passkeys: the all-passkey roster shown after it is valid in every mode, and passkeys count toward signingThreshold. (The stricter programmatic mode rejects api_key admins — there the machine signers take role: "signer" and admins are passkeys; 422 SIGNING_MODE_ROSTER_INVALID otherwise.)Conduit sets the signing mode per organization (new sandbox organizations get programmatic_unattended); it is not a self-serve field. A customer’s mode surfaces publicly as the signingMode value on the transaction.awaiting_signature webhook. If a customer resolves to passkey_required instead — older organizations do — use the passkey roster variant shown after the machine-signer example below, because that mode refuses any api_key member.Machine (api_key) roster members need no enrollment ceremony, so the wallets activate on their own shortly after the claim (await claim.completed) and you skip the enrollment poll in Step 5.3. You then approve each payout with a machine stamp; see Machine-signer stamping for generating the keypair and stamping.signingThreshold: 2), the minimal all-machine roster under programmatic_unattended. Each member’s publicKey is a compressed key you generate and hold. Omit signatureScheme, as below, and the key is a P-256 (secp256r1) one; set it to secp256k1 to register a secp256k1 key that signs the same SHA-256 digest, or to secp256k1_eip191 for a secp256k1 key that approves with an EVM personal_sign. See Machine-signer stamping § Generate a machine signing keypair.
publicKey is the compressed public half of a keypair you generate and hold — P-256 here, because these members send no signatureScheme; the two above are examples, substitute your own. Conduit never holds the private half. Because both members are api_key, the roster reaches its 2-of-2 quorum with machine stamps and there is no passkey enrollment step. The claim returns 202 with status: "provisioning"; activation then completes on its own and claim.completed fires. Await claim.completed, or poll GET /v2/customers/:id/wallets until each chain is status: "active", then skip the enrollment loop in Step 5.3. To sign a payout later, stamp the signingRequestId from the transaction.awaiting_signature webhook at POST /v2/signing-requests/:id/approve — full loop in Machine-signer stamping.
Passkey roster variant (every signing mode)
Passkey roster variant (every signing mode)
Every signing mode accepts an all-This all-
passkey roster, and it is the only choice when your customer resolves to passkey_required (Conduit sets this; older organizations default to it) — at least 2 members, at least 2 admins. Each member then enrols a passkey before the wallets activate, so follow Step 5.3.passkey roster is valid in every signing mode, including programmatic_unattended (the sandbox default). Each payout then waits for passkey approvals on the Conduit-hosted verification page.Step 5.3 — Enroll the roster (passkey roster only)
Machine-signer roster? Skip the enrollment loop below.
api_key members need no passkey enrollment, so activation completes on its own: the claim returns status: "provisioning" (rosterSize: 2, signingThreshold: 2), then claim.completed fires. Await that webhook, or poll GET /v2/customers/:id/wallets until each requested chain is status: "active", then go to Step 6.202 Accepted with a claimId, rosterSize: 3, and signingThreshold: 2. Each roster member receives a wallet_signer.invited webhook with their own verificationUrl. Each signer opens that URL on their device and registers a passkey: admins register two passkeys, signers one. wallet_signer.enrolled fires for each signer when enrollment completes. A passkey needs a person at a device, so this step cannot run from a script. For a headless run, use the machine-signer roster above.
Once the last signer activates, the wallet account auto-activates and claim.completed fires, carrying the claimId and the activated walletIds (a customer-level crypto_wallet.completed, carrying customerId only, also fires). Verify with GET /v2/customers/:id/wallet-signers (all three rows now status: "active") and GET /v2/customers/:id/wallets (one row per requested chain, each status: "active"; the EVM chains — here ethereum and polygon — share one on-chain address, while solana and tron each get their own). At this point POST /v2/customers/:id/wallets { chain } succeeds for any additional supported chain except stellar, which a non-custodial customer cannot hold yet and which returns 422 NON_CUSTODIAL_CHAIN_NOT_SUPPORTED; called before the customer has claimed non-custodial control, it returns 422 WALLET_NO_PROVIDER_ACCOUNT.
To move crypto from this wallet, fund it from a faucet. The deployed sandbox runs a real public testnet, so an offramp, crypto withdrawal, or crypto conversion from this wallet broadcasts for real. Crypto deposit simulation routes are unavailable. Send real test tokens to the wallet
address (Step 6 funds the fiat virtual account, not the crypto wallet). See Use a real testnet for the faucets, finality, and how to sign the outbound transfer.Step 6 - Fund the customer
Inject a synthetic USD deposit into the virtual account. The deposit completes automatically.202 Accepted with { externalReference } — the deposit is ingested asynchronously, just as a real bank notification is, so nothing is returned to read an id off. Your webhook endpoint receives transaction.created followed by transaction.completed within a few seconds; to poll instead, use GET /v2/transactions?type=deposit&externalReference=…. The customer’s USD balance is now 1000.00.
Step 7 - Upload a supporting document
Payouts require at least one supporting document. Upload a minimal PDF here and pass itsid in the next step.
201 Created. Capture id as $DOC_ID / docId / doc_id.
Step 8 - First payout
A USD payout via FedWire. The account number below has no magic suffix, so compliance clears automatically.202 Accepted with { "id": "txn_...", "status": "pending" }. Capture the id.
Step 9 - Approve the document review
When the documentation policy requires a document for the payout’spurpose, the payout parks at a document-review gate before broadcasting, whether or not the body already includes documents: [...]. In production a human reviewer approves the documents; in sandbox you drive that decision with the call below. Without this step a payout that attached its document sits at status: "pending" until an analyst decides; one that attached none is asked for the document instead, and that request expires after 48 hours.
200 OK returns the transaction payload. The payout resumes past the gate, broadcasts the wire, and posts the settlement leg automatically. Within a few seconds GET /v2/transactions/${TXN_ID} shows status: "completed" and your webhook receives transaction.completed.
No separate
simulate/settled call is needed in this flow. The sandbox
fiat-rail provider settles immediately after the review-approval gate
releases. POST /v2/sandbox/payouts/:id/simulate/settled is for payouts that
took the no-document path (e.g. purpose: "intercompany" with a whitelisted
recipient) and parked at the settlement gate instead of the document-review
gate; calling it on a payout that already completed returns 409 CONFLICT.Verify
Thetransaction.completed event your endpoint receives has this shape:
status: "completed" confirms the lifecycle is complete.
Fee accounting. A FEDWIRE payout carries a fixed fee (here 0.25 USD). When Conduit takes its fee out of the movement, one identity holds in both directions: source.assetAmount − destination.assetAmount = fees. It does not hold when Conduit invoices its fee to you separately instead of collecting it from the transaction: that fee is still reported in fees[], and it moves neither side amount, so the two agree unless another charge such as your own margin comes out of the movement. The source side is the gross amount; the destination side is what arrived. On a payout that puts the fee on top of the principal — a 25.00 USD recipient credit shows source.assetAmount: "25.25", so branching on source.assetAmount === "25.00" will miss every fee-bearing payout. On an inbound deposit charged a fee it runs the other way: the fee comes out of the credit, so source.assetAmount is what the sender sent and destination.assetAmount is the smaller amount that was credited. Either way, branch on destination.assetAmount for what reached the destination and read fees[] for the charge — don’t assume which side carries the principal.
fedwireImad shape. In sandbox the value is a synthetic 32-character lowercase hex string (e.g. 8c5d129f9f2e47baf76260e03d902e95); in production it follows the standard Fedwire IMAD format (YYMMDDISSSSSSSSC from the originating bank). Both arrive on destination.fedwireImad. The crypto-rail equivalent destination field is txHash.
Where to go next
You’ve completed a full sandbox transaction lifecycle. Explore the per-flow guides for deeper coverage of failure paths, scenario libraries, and all transaction types:- Deposits - fiat simulation, real testnet wallet funding, sender-information gate
- Withdrawals - crypto and fiat withdrawals, failure paths
- Conversions - FX conversion sub-flow, rate-stale and provider-unavailable scenarios
- Onramps - fiat-in to crypto-out order lifecycle
- Offramps - crypto-in to fiat-out order lifecycle