Skip to main content
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.
Set up the environment once:
export SANDBOX_HOST="https://api.sandbox.conduit.financial"
export SANDBOX_API_KEY="ck_sandbox_..."
export WEBHOOK_URL="https://webhook.site/<your-token>"

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 returned id for Step 2.
KYB_DOC_ID=$(curl -s -X POST "${SANDBOX_HOST}/v2/documents" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -F "file=@./articles-of-incorporation.pdf;type=application/pdf" \
  -F "purpose=kyc" | jq -r '.id')
echo "KYB document ID: $KYB_DOC_ID"
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[], individualRequirements[] }. The body below is one valid US shape, not a fixed contract. Sandbox and production use the same requirements.
APP_ID=$(curl -s -X POST "${SANDBOX_HOST}/v2/onboarding" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "businessInfo": {
      "businessName": "Aurora Robotics Inc.",
      "businessEntityId": "4567890",
      "taxId": "47-1234567",
      "website": "https://aurorarobotics.example",
      "contactInformation": "+12125550199"
    },
    "registeredAddress": {
      "country": "USA",
      "addressLine1": "270 Park Ave",
      "city": "New York",
      "state": "US-NY",
      "zipcode": "10017"
    },
    "companyClassification": {
      "legalStructure": "Limited Liability Company (multi-member)",
      "incorporationDate": "2020-01-15",
      "coreIndustry": "Financial Technology"
    },
    "businessActivity": {
      "businessActivitiesDescription": "Treasury management for robotics operations",
      "accountPurpose": ["Treasury Management"],
      "productsServices": ["Digital Wallet"],
      "isRegulated": false,
      "operatesOnBehalf": false,
      "countriesOfActivity": ["USA"],
      "avgMonthlyVolume": "VOLUME_10K_50K",
      "estimatedTransactionsPerMonth": "10-50",
      "usesBlockchainWallets": false,
      "fundFlowDescription": "Revenue in, expenses out",
      "sourceOfFunds": ["Revenue/Sales"],
      "isGeneratingRevenue": true,
      "revenueCovers": "All operating expenses",
      "hasInstitutionalInvestors": false,
      "financialRunway": "RUNWAY_GT_12M",
      "cashOnHand": "$1M-$5M"
    },
    "regulatoryHistory": {
      "hasUSBankAccount": true,
      "deniedBankAccount": false,
      "hasPoliticallyExposedPersons": false,
      "businessAdverseActions": ["None"],
      "ownersDirectorsAdverseActions": ["None"]
    },
    "ownership": {
      "persons": [{
        "firstName": "Aiko",
        "lastName": "Tanaka",
        "email": "aiko.tanaka@aurorarobotics.example",
        "phoneNumber": "+12125550199",
        "birthDate": "1988-06-15",
        "nationality": "USA",
        "governmentIdType": "SSN",
        "governmentIdNumber": "123-45-6789",
        "governmentIdCountry": "USA",
        "ssn": "123-45-6789",
        "taxResidencyCountry": "USA",
        "ownershipPercent": 100,
        "sharesAllocated": 1000,
        "roles": ["BENEFICIAL_OWNER", "CONTROLLING_PERSON"]
      }]
    },
    "certification": {
      "consentToElectronicSignatures": true,
      "termsAndConditions": true,
      "treasuryOnlyCertification": true
    },
    "documentIds": ["'"$KYB_DOC_ID"'"]
  }' | jq -r '.id')
echo "Application ID: $APP_ID"
Returns 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 to APPROVED. 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}.
curl -s -X POST "${SANDBOX_HOST}/v2/sandbox/applications/${APP_ID}/simulate/decision" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "approved" }'
Returns 200 with the application object. Your webhook receives application.approved carrying customerId. Pull the customerId from the webhook payload or fetch the application again.
A second simulate/decision call on the same application returns 409 Conflict. First call wins. If you hit 409, fetch the application to confirm its current status before retrying.
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.
VA_APP_ID=$(curl -s -X POST "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/features" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "VIRTUAL_ACCOUNT",
    "asset": "USD"
  }' | jq -r '.id')
echo "VA Application ID: $VA_APP_ID"
202 Accepted returns the application with status: "approved" immediately — feature applications with no extra review payload auto-approve on submission. The provisioning workflow runs asynchronously; your webhook endpoint receives virtual_account.activated { virtualAccountId: vac_... }, and GET /v2/customers/${CUSTOMER_ID}/virtual-accounts shows the new VA status: "active" within a couple of seconds. Capture the vac_... id 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 → create one wallet per chain. Calling POST /v2/customers/:id/wallets before the claim returns 409 WALLET_CUSTODY_NOT_CLAIMED. The custodial path is reserved for legacy customers provisioned before the non-custodial gate; converting one of those uses POST /v2/customers/:id/features with { "type": "WALLET_CUSTODY_CONVERSION" } and is documented at Custodial vs non-custodial.

Step 5.1 — Claim non-custodial control

POST /v2/customers/:id/wallets/claim-non-custodial is the entry point for fresh KYB-approved customers; it provisions the non-custodial wallet account, writes the CRYPTO_WALLET feature row, and mints invitations for every roster member. No prior POST /features { type: CRYPTO_WALLET } is required. The DTO requires at least 2 roster members and 2 admins; the example below uses a 2-of-3 roster (2 admins + 1 signer, signingThreshold: 2).
curl -s -X POST "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/wallets/claim-non-custodial" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "roster": [
      { "email": "alice@example.com", "role": "admin",  "credentialType": "passkey" },
      { "email": "bob@example.com",   "role": "admin",  "credentialType": "passkey" },
      { "email": "carol@example.com", "role": "signer", "credentialType": "passkey" }
    ],
    "signingThreshold": 2,
    "chains": ["ethereum", "polygon"]
  }'

Step 5.2 — Enroll the roster

The endpoint returns 202 Accepted with a claimId, rosterSize: 3, and signingThreshold: 2. Each roster member receives an invitation (wallet_signer.invited webhook) and must complete passkey enrollment. The signer rows are minted asynchronously by the claim workflow, so the list call below polls until all three appear before driving each headless enrollment:
# Bounded poll until the workflow has minted the full roster (typically <2s).
for i in {1..20}; do
  COUNT=$(curl -s "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/wallet-signers" \
    -H "x-api-key: ${SANDBOX_API_KEY}" | jq '.data | length')
  [ "${COUNT}" -ge 3 ] && break
  sleep 1
done

# Drive each signer to ACTIVE headlessly.
curl -s "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/wallet-signers" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  | jq -r '.data[] | .id' | while read -r SIGNER_ID; do
  curl -s -X POST "${SANDBOX_HOST}/v2/sandbox/wallet-signers/${SIGNER_ID}/mark-enrolled" \
    -H "x-api-key: ${SANDBOX_API_KEY}" > /dev/null
done
Once the last signer activates, the wallet account auto-activates and crypto_wallet.completed fires for each requested chain (Ethereum, Polygon). 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" with a deterministic address). At this point POST /v2/customers/:id/wallets { chain } will succeed for any additional chain you want; called before the claim or before enrollment completes, it returns 409 WALLET_CUSTODY_NOT_CLAIMED. See Custodial vs non-custodial for the full mental model.

Step 6 - Fund the customer

Inject a synthetic USD deposit into the virtual account. The deposit completes automatically.
curl -X POST "${SANDBOX_HOST}/v2/sandbox/customers/${CUSTOMER_ID}/virtual-accounts/${VAC_ID}/deposits/simulate" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "assetAmount": { "code": "USD", "amount": "1000.00" } }'
201 Created. Your webhook endpoint receives transaction.created followed by transaction.completed within a few seconds. 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 its id in the next step.
DOC_ID=$(curl -s -X POST "${SANDBOX_HOST}/v2/documents" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -F "file=@invoice.pdf;type=application/pdf" \
  -F "purpose=transaction_support" \
  | jq -r '.id')
echo "Document ID: $DOC_ID"
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.
TXN_ID=$(curl -s -X POST "${SANDBOX_HOST}/v2/payouts" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "'"${CUSTOMER_ID}"'",
    "virtualAccountId": "'"${VAC_ID}"'",
    "assetAmount": { "code": "USD", "amount": "25.00" },
    "purpose": "TREASURY_MANAGEMENT",
    "documents": ["'"${DOC_ID}"'"],
    "destination": {
      "type": "fiat",
      "rail": "FEDWIRE",
      "recipient": {
        "rail": "US",
        "type": "INDIVIDUAL",
        "firstName": "Aiko",
        "lastName": "Tanaka",
        "accountNumber": "000094300000",
        "routingNumber": "021000021",
        "accountType": "CHECKING",
        "bankName": "Chase Bank",
        "bankAddress": {
          "addressLine1": "270 Park Ave",
          "city": "New York",
          "state": "NY",
          "postalCode": "10017",
          "country": "USA"
        },
        "phone": "+12125550199",
        "postalAddress": {
          "addressLine1": "270 Park Ave",
          "city": "New York",
          "state": "NY",
          "postalCode": "10017",
          "country": "USA"
        }
      }
    }
  }' | jq -r '.id')
echo "Transaction ID: $TXN_ID"
202 Accepted with { "id": "txn_...", "status": "pending" }. Capture the id.

Step 9 - Approve the document review

When a payout’s body includes documents: [...] (and purpose isn’t INTERCOMPANY), the workflow parks at a document-review gate before broadcasting. In production a human reviewer approves the documents; in sandbox you drive that decision with the call below. Without this step the payout sits at status: "pending" indefinitely.
curl -X POST "${SANDBOX_HOST}/v2/sandbox/payouts/${TXN_ID}/simulate-review-approve" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'
200 OK returns the transaction payload. The workflow 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

The transaction.completed event your endpoint receives has this shape:
{
  "type": "transaction.completed",
  "data": {
    "transactionId": "txn_...",
    "customerId": "cus_...",
    "type": "withdrawal",
    "status": "completed",
    "source": {
      "type": "virtual_account",
      "virtualAccountId": "vac_...",
      "assetAmount": { "code": "USD", "amount": "25.25" }
    },
    "destination": {
      "type": "external_bank",
      "recipient": { "rail": "us" },
      "assetAmount": { "code": "USD", "amount": "25.00" },
      "fedwireImad": "8c5d129f9f2e47baf76260e03d902e95"
    },
    "fees": [
      {
        "code": "FIXED_FEE",
        "type": "fixed",
        "assetAmount": { "code": "USD", "amount": "0.25" }
      }
    ],
    "completedAt": "..."
  }
}
status: "completed" confirms the lifecycle is complete. Fee accounting. A FEDWIRE payout carries a FIXED_FEE (here 0.25 USD). The fee is debited from the source on top of the principal: source.assetAmount = principal + fees, so a 25.00 USD recipient credit shows source.assetAmount: "25.25". Branching on source.assetAmount === "25.00" will miss every fee-bearing payout. Either branch on destination.assetAmount (the principal that lands at the recipient) or read fees[] and reconstruct. 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 and crypto deposit simulation, sender-information gate
  • Withdrawals - custodial and non-custodial crypto withdrawals, 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
  • Custodial vs non-custodial - side-by-side mental model

See also