> ## Documentation Index
> Fetch the complete documentation index at: https://v2.docs.conduit.financial/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox quickstart

> Zero to first transaction in under 10 minutes. API key to a transaction.completed webhook, one linear page.

<Note>
  **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](/sandbox/example-data); none of the values collide with magic suffixes.
</Note>

## Prerequisites

* A sandbox API key (`ck_sandbox_...`). Find yours in the dashboard under API Keys.
* A webhook endpoint URL. Use [webhook.site](https://webhook.site) as a free stand-in for a real endpoint.
* For the bash track: `curl`, `jq`, and `uuidgen` on your PATH.

Set up the environment once:

```bash theme={null}
export SANDBOX_HOST="https://api.sandbox.conduit.financial"
export SANDBOX_API_KEY="ck_sandbox_..."
export WEBHOOK_URL="https://webhook.site/<your-token>"

# Generate the two PDF fixtures the steps below upload. Only the magic bytes
# are validated in sandbox, so a stub file works.
printf '%%PDF-1.4\nquickstart fixture\n%%%%EOF\n' > articles-of-incorporation.pdf
printf '%%PDF-1.4\nquickstart fixture\n%%%%EOF\n' > invoice.pdf
```

Then register the URL as a webhook endpoint — every webhook this page tells you to watch for (`application.approved`, `virtual_account.activated`, `transaction.*`) is delivered only to registered endpoints:

```bash theme={null}
curl -s -X POST "${SANDBOX_HOST}/v2/webhooks/endpoints" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "url": "'"${WEBHOOK_URL}"'" }'
```

Returns `201 Created`. Deliveries to this URL are HMAC-signed; see the [Webhooks reference](/webhooks) for signature verification.

## How the flow works

```mermaid theme={null}
sequenceDiagram
    participant You as Your backend
    participant API as Conduit sandbox
    participant WH as Your webhook endpoint

    You->>API: POST /v2/webhooks/endpoints { url }
    API-->>You: 201 { id: wep_... }
    You->>API: POST /v2/documents (KYB doc, purpose: kyc)
    API-->>You: 201 { id: doc_... }
    You->>API: POST /v2/onboarding (with documentIds: [doc_...])
    API-->>You: 202 { id: app_..., status: processing }
    You->>API: POST /v2/sandbox/applications/:id/simulate/decision {outcome: approved}
    API-->>You: 200 { id: app_..., status: approved }
    API-->>WH: application.approved { customerId: cus_... }
    You->>API: POST /v2/customers/:id/features (VIRTUAL_ACCOUNT, asset: USD)
    API-->>You: 202 { id: app_..., status: approved }
    API-->>WH: virtual_account.activated { virtualAccountId: vac_... }
    You->>API: POST /v2/sandbox/customers/:id/virtual-accounts/:vacId/deposits/simulate
    API-->>WH: transaction.created
    API-->>WH: transaction.completed (DEPOSIT)
    You->>API: POST /v2/documents (purpose: transaction_support)
    API-->>You: 201 { id: doc_... }
    You->>API: POST /v2/payouts { purpose, documents: [doc_...] }
    API-->>You: 202 { id: txn_..., status: pending }
    You->>API: POST /v2/sandbox/payouts/:id/simulate-review-approve
    API-->>You: 200 (transaction payload)
    API-->>WH: transaction.completed (WITHDRAWAL)
```

## 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.

<CodeGroup>
  ```bash bash theme={null}
  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"
  ```

  ```typescript typescript theme={null}
  import fs from "node:fs";

  const form = new FormData();
  form.append("purpose", "kyc");
  form.append(
    "file",
    new Blob([fs.readFileSync("./articles-of-incorporation.pdf")], { type: "application/pdf" }),
    "articles-of-incorporation.pdf",
  );

  const docRes = await fetch(`${process.env.SANDBOX_HOST}/v2/documents`, {
    method: "POST",
    headers: {
      "x-api-key": process.env.SANDBOX_API_KEY!,
      "idempotency-key": crypto.randomUUID(),
    },
    body: form,
  });
  const { id: kybDocId } = await docRes.json();
  ```

  ```python python theme={null}
  import httpx, os, uuid

  with open("./articles-of-incorporation.pdf", "rb") as f:
      r = httpx.post(
          f"{os.environ['SANDBOX_HOST']}/v2/documents",
          headers={
              "x-api-key": os.environ["SANDBOX_API_KEY"],
              "idempotency-key": str(uuid.uuid4()),
          },
          data={"purpose": "kyc"},
          files={"file": ("articles-of-incorporation.pdf", f, "application/pdf")},
      )
  kyb_doc_id = r.json()["id"]
  ```
</CodeGroup>

`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.

<Note>
  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.
</Note>

<CodeGroup>
  ```bash bash theme={null}
  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": {
        "legalName": "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",
        "postalCode": "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",
          "taxIdType": "SSN",
          "taxIdNumber": "123-45-6789",
          "taxIdCountry": "USA",
          "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"
  ```

  ```typescript typescript theme={null}
  const res = await fetch(`${process.env.SANDBOX_HOST}/v2/onboarding`, {
    method: "POST",
    headers: {
      "x-api-key": process.env.SANDBOX_API_KEY!,
      "idempotency-key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      businessInfo: {
        legalName: "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",
        postalCode: "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",
          taxIdType: "SSN",
          taxIdNumber: "123-45-6789",
          taxIdCountry: "USA",
          taxResidencyCountry: "USA",
          ownershipPercent: 100,
          sharesAllocated: 1000,
          roles: ["BENEFICIAL_OWNER", "CONTROLLING_PERSON"],
        }],
      },
      certification: {
        consentToElectronicSignatures: true,
        termsAndConditions: true,
        treasuryOnlyCertification: true,
      },
      documentIds: [kybDocId],
    }),
  });
  const { id: appId } = await res.json();
  ```

  ```python python theme={null}
  import httpx, os, uuid

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/onboarding",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={
          "businessInfo": {
              "legalName": "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",
              "postalCode": "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",
                  "taxIdType": "SSN",
                  "taxIdNumber": "123-45-6789",
                  "taxIdCountry": "USA",
                  "taxResidencyCountry": "USA",
                  "ownershipPercent": 100,
                  "sharesAllocated": 1000,
                  "roles": ["BENEFICIAL_OWNER", "CONTROLLING_PERSON"],
              }],
          },
          "certification": {
              "consentToElectronicSignatures": True,
              "termsAndConditions": True,
              "treasuryOnlyCertification": True,
          },
          "documentIds": [kyb_doc_id],
      },
  )
  app_id = r.json()["id"]
  ```
</CodeGroup>

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}`.

<CodeGroup>
  ```bash bash theme={null}
  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" }'
  ```

  ```typescript typescript theme={null}
  await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/applications/${appId}/simulate/decision`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ outcome: "approved" }),
    }
  );
  ```

  ```python python theme={null}
  import httpx, os

  httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/sandbox/applications/{app_id}/simulate/decision",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "Content-Type": "application/json",
      },
      json={"outcome": "approved"},
  )
  ```
</CodeGroup>

Returns `200` with the application object. Your webhook receives `application.approved` carrying `customerId`. The id is minted asynchronously, so poll the application until it appears:

<CodeGroup>
  ```bash bash theme={null}
  # Bounded poll until customerId lands (typically <2s).
  for i in {1..20}; do
    CUSTOMER_ID=$(curl -s "${SANDBOX_HOST}/v2/applications/${APP_ID}" \
      -H "x-api-key: ${SANDBOX_API_KEY}" | jq -r '.customerId // empty')
    [ -n "${CUSTOMER_ID}" ] && break
    sleep 1
  done
  : "${CUSTOMER_ID:?never appeared — check the approval response above before continuing}"
  echo "Customer ID: ${CUSTOMER_ID}"
  ```

  ```typescript typescript theme={null}
  let customerId: string | undefined;
  for (let i = 0; i < 20 && !customerId; i++) {
    const res = await fetch(
      `${process.env.SANDBOX_HOST}/v2/applications/${appId}`,
      { headers: { "x-api-key": process.env.SANDBOX_API_KEY! } }
    );
    ({ customerId } = await res.json());
    if (!customerId) await new Promise((r) => setTimeout(r, 1000));
  }
  if (!customerId) throw new Error("customerId never appeared — check the approval response");
  ```

  ```python python theme={null}
  import httpx, os, time

  customer_id = None
  for _ in range(20):
      r = httpx.get(
          f"{os.environ['SANDBOX_HOST']}/v2/applications/{app_id}",
          headers={"x-api-key": os.environ["SANDBOX_API_KEY"]},
      )
      customer_id = r.json().get("customerId")
      if customer_id:
          break
      time.sleep(1)
  assert customer_id, "customerId never appeared — check the approval response"
  ```
</CodeGroup>

Every later step uses this `CUSTOMER_ID`.

<Warning>
  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.
</Warning>

<Note>
  You can skip this call entirely. The sandbox auto-approves customer onboarding applications after one hour. Calling `simulate/decision` is faster for testing.
</Note>

***

## Step 4 - Create a virtual account feature

Apply for a USD virtual account on the new customer.

<CodeGroup>
  ```bash bash theme={null}
  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": { "code": "USD" }
    }' | jq -r '.id')
  echo "VA Application ID: $VA_APP_ID"
  ```

  ```typescript typescript theme={null}
  const res = await fetch(
    `${process.env.SANDBOX_HOST}/v2/customers/${customerId}/features`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        type: "virtual_account",
        asset: { code: "USD" },
      }),
    }
  );
  const { id: vaAppId } = await res.json();
  ```

  ```python python theme={null}
  import httpx, os, uuid

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/customers/{customer_id}/features",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={
          "type": "virtual_account",
          "asset": { "code": "USD" },
      },
  )
  va_app_id = r.json()["id"]
  ```
</CodeGroup>

`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`:

<CodeGroup>
  ```bash bash theme={null}
  # Bounded poll until the VA activates (typically <2s).
  for i in {1..20}; do
    VAC_ID=$(curl -s "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/virtual-accounts" \
      -H "x-api-key: ${SANDBOX_API_KEY}" \
      | jq -r '[.data[] | select(.status == "active")][0].id // empty')
    [ -n "${VAC_ID}" ] && break
    sleep 1
  done
  : "${VAC_ID:?no active virtual account appeared — check the feature response above before continuing}"
  echo "Virtual account ID: ${VAC_ID}"
  ```

  ```typescript typescript theme={null}
  let vacId: string | undefined;
  for (let i = 0; i < 20 && !vacId; i++) {
    const res = await fetch(
      `${process.env.SANDBOX_HOST}/v2/customers/${customerId}/virtual-accounts`,
      { headers: { "x-api-key": process.env.SANDBOX_API_KEY! } }
    );
    const { data } = await res.json();
    vacId = data.find((va: { status: string }) => va.status === "active")?.id;
    if (!vacId) await new Promise((r) => setTimeout(r, 1000));
  }
  if (!vacId) throw new Error("no active virtual account appeared — check the feature response");
  ```

  ```python python theme={null}
  import httpx, os, time

  vac_id = None
  for _ in range(20):
      r = httpx.get(
          f"{os.environ['SANDBOX_HOST']}/v2/customers/{customer_id}/virtual-accounts",
          headers={"x-api-key": os.environ["SANDBOX_API_KEY"]},
      )
      vac_id = next(
          (va["id"] for va in r.json()["data"] if va["status"] == "active"), None
      )
      if vac_id:
          break
      time.sleep(1)
  assert vac_id, "no active virtual account appeared — check the feature response"
  ```
</CodeGroup>

<Note>
  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`.
</Note>

***

## 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 every supported chain by default, 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 you excluded from the claim. The custodial path is reserved for legacy customers provisioned before the non-custodial gate and is documented at [Custodial vs non-custodial](/sandbox/custody).

### Step 5.1 — Enable the CRYPTO\_WALLET feature

Before claim-non-custodial, the customer must have an approved `CRYPTO_WALLET` feature on file. Submit it first:

```bash theme={null}
curl -X POST "${SANDBOX_HOST}/v2/customers/${CUSTOMER_ID}/features" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "type": "crypto_wallet" }'
```

In sandbox the default is auto-approve: the response carries `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 example below uses a 2-of-3 roster (2 admins + 1 signer, `signingThreshold: 2`).

<CodeGroup>
  ```bash bash theme={null}
  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"]
    }'
  ```

  ```typescript typescript theme={null}
  await fetch(
    `${process.env.SANDBOX_HOST}/v2/customers/${customerId}/wallets/claim-non-custodial`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        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"],
      }),
    }
  );
  ```

  ```python python theme={null}
  import httpx, os, uuid

  httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/customers/{customer_id}/wallets/claim-non-custodial",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={
          "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"],
      },
  )
  ```
</CodeGroup>

### Step 5.3 — 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, so the list call below polls until all three appear before driving each headless enrollment:

```bash theme={null}
# Bounded poll until the full roster is minted (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 `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 }` will succeed for any additional chain you want; called before the customer has claimed non-custodial control, it returns `422 WALLET_NO_PROVIDER_ACCOUNT`. See [Custodial vs non-custodial](/sandbox/custody) for the full mental model.

***

## Step 6 - Fund the customer

Inject a synthetic USD deposit into the virtual account. The deposit completes automatically.

<CodeGroup>
  ```bash bash theme={null}
  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" } }'
  ```

  ```typescript typescript theme={null}
  const res = await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/customers/${customerId}/virtual-accounts/${vacId}/deposits/simulate`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ assetAmount: { code: "USD", amount: "1000.00" } }),
    }
  );
  const { externalReference } = await res.json();
  ```

  ```python python theme={null}
  import httpx, os, uuid

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/sandbox/customers/{customer_id}/virtual-accounts/{vac_id}/deposits/simulate",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={"assetAmount": {"code": "USD", "amount": "1000.00"}},
  )
  external_reference = r.json()["externalReference"]
  ```
</CodeGroup>

`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 its `id` in the next step.

<CodeGroup>
  ```bash bash theme={null}
  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"
  ```

  ```typescript typescript theme={null}
  const form = new FormData();
  // Any PDF file works in sandbox — the validator only checks the %PDF- header.
  form.append(
    "file",
    new Blob(["%PDF-1.4\nspeedrun"], { type: "application/pdf" }),
    "invoice.pdf",
  );
  form.append("purpose", "transaction_support");

  const docRes = await fetch(`${process.env.SANDBOX_HOST}/v2/documents`, {
    method: "POST",
    headers: { "x-api-key": process.env.SANDBOX_API_KEY! },
    body: form,
  });
  const { id: docId } = await docRes.json();
  ```

  ```python python theme={null}
  import httpx, os

  doc_r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/documents",
      headers={"x-api-key": os.environ["SANDBOX_API_KEY"]},
      files={"file": ("invoice.pdf", b"%PDF-1.4\nspeedrun", "application/pdf")},
      data={"purpose": "transaction_support"},
  )
  doc_id = doc_r.json()["id"]
  ```
</CodeGroup>

`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.

<CodeGroup>
  ```bash bash theme={null}
  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"
  ```

  ```typescript typescript theme={null}
  const res = await fetch(`${process.env.SANDBOX_HOST}/v2/payouts`, {
    method: "POST",
    headers: {
      "x-api-key": process.env.SANDBOX_API_KEY!,
      "idempotency-key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      customerId,
      virtualAccountId: vacId,
      assetAmount: { code: "USD", amount: "25.00" },
      purpose: "treasury_management",
      documents: [docId],
      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",
          },
        },
      },
    }),
  });
  const { id: txnId } = await res.json();
  ```

  ```python python theme={null}
  import httpx, os, uuid

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/payouts",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={
          "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",
                  },
              },
          },
      },
  )
  txn_id = r.json()["id"]
  ```
</CodeGroup>

`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 payout 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.

<CodeGroup>
  ```bash bash theme={null}
  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 '{}'
  ```

  ```typescript typescript theme={null}
  await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/payouts/${txnId}/simulate-review-approve`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({}),
    }
  );
  ```

  ```python python theme={null}
  import httpx, os, uuid

  httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/sandbox/payouts/{txn_id}/simulate-review-approve",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={},
  )
  ```
</CodeGroup>

`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`.

<Note>
  **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`.
</Note>

***

## Verify

The `transaction.completed` event your endpoint receives has this shape:

```json theme={null}
{
  "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": [
      {
        "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](/sandbox/deposits) - fiat and crypto deposit simulation, sender-information gate
* [Withdrawals](/sandbox/withdrawals) - custodial and non-custodial crypto withdrawals, fiat withdrawals, failure paths
* [Conversions](/sandbox/conversions) - FX conversion sub-flow, rate-stale and provider-unavailable scenarios
* [Onramps](/sandbox/onramps) - fiat-in to crypto-out order lifecycle
* [Offramps](/sandbox/offramps) - crypto-in to fiat-out order lifecycle
* [Custodial vs non-custodial](/sandbox/custody) - side-by-side mental model

## See also

* [Sandbox overview](/sandbox/overview)
* [Webhooks reference](/webhooks)
* [Error codes](/errors)
