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

# Withdrawals in sandbox

> End-to-end guide for testing crypto and fiat withdrawals: custodial happy path, non-custodial cosign, fiat settlement, failures, and webhooks

The sandbox cluster is fully mocked. There is no real chain, no real signing, no third-party vendor calls. Every outcome is deterministic and driven by your request data (destination-address suffixes or bank-account suffixes) or by sandbox-only `simulate/*` endpoints. The synthetic `txHash` you receive is shaped like a real one but does not appear on any explorer. See [Sandbox overview](/sandbox/overview) for the full posture.

This page walks the full lifecycle of both withdrawal types end-to-end:

* **Crypto withdrawal:** mocked chain broadcast, `payouts/:id/simulate/confirm` drives finality.
* **Fiat withdrawal:** bank-recipient payout, `payouts/:id/simulate/settled` drives settlement.

For crypto withdrawals, two custody flavors are covered:

* **Custodial:** Conduit signs and broadcasts on the customer's behalf.
* **Non-custodial:** the customer cosigns each outbound transfer; sandbox bypasses the real passkey UI via simulate endpoints.

## Withdrawal state machine

<Tabs>
  <Tab title="Custodial">
    ```mermaid theme={null}
    stateDiagram-v2
        [*] --> pending
        pending --> document_review: documents attached (auto)
        pending --> broadcasting: compliance cleared, no documents (auto)
        document_review --> broadcasting: simulate-review-approve
        document_review --> failed: simulate-review-reject {compliance_rejected}
        broadcasting --> completed: simulate/confirm {outcome: completed} / simulate/settled {outcome: completed}
        broadcasting --> failed: simulate/confirm {outcome: failed} / simulate/broadcast-fail / simulate/settled {outcome: failed}
        pending --> failed: Travel Rule reject
        pending --> compliance_review: compliance reject (held for review)
        compliance_review --> failed: simulate/compliance-decision {reject}
    ```
  </Tab>

  <Tab title="Non-custodial">
    ```mermaid theme={null}
    stateDiagram-v2
        [*] --> pending
        pending --> document_review: created, documents attached
        pending --> pending_cosign: created, no documents (compliance + Travel Rule clear -> processing)
        document_review --> pending_cosign: simulate-review-approve (compliance clears -> processing)
        document_review --> failed: simulate-review-reject {compliance_rejected}
        pending --> failed: Travel Rule reject
        pending --> compliance_review: compliance reject (held for review)
        compliance_review --> failed: simulate/compliance-decision {reject}
        pending_cosign --> broadcasting: simulate/cosign {outcome: approved}
        pending_cosign --> failed: simulate/cosign {outcome: declined}
        broadcasting --> completed: chain-confirm autopilot (~5s)
    ```
  </Tab>
</Tabs>

Annotations: a payout that carries `documents` parks for document review during compliance screening, before the customer is asked to sign — call `payouts/:id/simulate-review-approve` to resume it, or `simulate-review-reject` to terminate it as `failed` with `failureCode: compliance_rejected` (the reserved funds return to your available balance). A payout with no `documents` (`intercompany` payouts, which use a whitelist recipient, and by default `prefunding` payouts) skips the gate. Because document review is part of the pre-signing compliance stage, the payout reads as `status: "pending"` while parked here — it flips to `"processing"` only once compliance + Travel Rule clear (`transaction.processing`). Travel Rule counterparty-webhook autopilot fires 10 s after the Travel Rule transfer is created on suffixes that encode a counterparty outcome — and since screening now runs before signing, that transfer is created once compliance + document review clear, not at payout creation — see [Travel Rule scenarios](/sandbox/travel-rule-scenarios); custodial payouts drive `broadcasting → completed` via `payouts/:id/simulate/confirm`; non-custodial payouts auto-advance about 5 s after cosign approved via the chain-finality autopilot (distinct timer from the counterparty-webhook autopilot). Fiat payouts use `payouts/:id/simulate/settled` to terminalize. Transaction-level `transactions/:id/simulate/terminal { outcome: "failed" }` can force accepted fiat payouts to `failed` earlier; `outcome: "completed"` requires settlement-ready state. The transaction-level endpoint supports `withdrawal`, `onramp`, `offramp`, and `deposit` transaction types.

## Prerequisites

* A sandbox API key for an `active` customer (the onboarding flow leaves the customer in this state). Set `SANDBOX_API_KEY` in your shell.
* The customer must have an `active` crypto wallet for the asset and chain you want to test. New customers provision non-custodial wallets via `POST /v2/customers/:id/wallets/claim-non-custodial`; without that claim, `POST /v2/customers/:id/wallets` rejects with `422 WALLET_NO_PROVIDER_ACCOUNT`. Custody is fixed at provisioning time and cannot be flipped later.
* For fiat withdrawals: the customer must have an `active` virtual account with a sufficient USD balance.

<Warning>
  **Legacy custodial customers stay on the custodial path.** Customers that were provisioned through the legacy `CRYPTO_WALLET` application before the non-custodial gate get `409 CUSTOMER_ALREADY_CUSTODIAL` if they call `claim-non-custodial`; multi-signer roster claim on existing custodial wallets will land in a follow-up. Fresh KYB-approved customers go straight to `claim-non-custodial`.
</Warning>

* Base URL: `https://api.sandbox.conduit.financial`.

All `curl` examples below use `{{apiKey}}`, `{{customerId}}`, `{{walletId}}`, and `{{payoutId}}` placeholders. Substitute the values from your sandbox setup.

## Lifecycle

A sandbox payout walks the same lifecycle as production. The ordering differs by custody model:

* **Custodial:** validate → reserve → compliance screen → travel-rule resolve → (document review if documents attached) → broadcast → await finality → settle.
* **Non-custodial:** validate → reserve → compliance screen → travel-rule resolve → (document review if documents attached) → queue → collect the customer's signatures → final co-sign → broadcast → await finality → settle. The full compliance screen and Travel Rule run **before** the customer is asked to sign, so screening happens before any signature is requested — the customer never signs a payment that then fails screening. A compliance rejection does not stop the payout automatically: it is held for review and then either released to proceed or confirmed as terminal (rejected or frozen); on a confirmed rejection the payout never broadcasts.

The lifecycle is identical to production; only the *decisions* differ:

| Step                                             | Production                                                                                                                                         | Sandbox                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| compliance screen                                | Real upstream call                                                                                                                                 | Mock; outcome from `destination.address` suffix                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| Travel Rule create                               | Real upstream call (VASP destinations)                                                                                                             | Mock; row persisted iff destination suffix matches a VASP scenario                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Cosign (non-custodial)                           | Customer passkey on verify page                                                                                                                    | `payouts/:id/simulate/cosign` (the wallet-keyed `wallets/:walletId/simulate-cosign-complete` lever has been removed; cosign resolution is now payout-keyed)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Document review (payouts that carry `documents`) | Conduit reviews the supporting documents before the payout proceeds; clear cases pass automatically, the rest are reviewed by a compliance analyst | `payouts/:id/simulate-review-approve` to resume the payout, or `payouts/:id/simulate-review-reject` to terminate it as `failed` with `failureCode: compliance_rejected` (reserved funds returned). There is no automatic pass in sandbox — you must call one of the two levers.                                                                                                                                                                                                                                                                                                                                                                                         |
| Chain broadcast                                  | Mainnet                                                                                                                                            | Mocked; deterministic synthetic `txHash`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Counterparty webhook                             | Real upstream delivery                                                                                                                             | Counterparty-webhook autopilot fires 10s after the Travel Rule transfer is created when the destination suffix encodes a counterparty outcome (`BAD6A1A4`, `DEC11A1D`, `ACCEEDED`). For both custody models the row is created before the payout would broadcast — custodial at payout creation, non-custodial during the pre-signing compliance/Travel Rule screen — so the 10s clock starts at Travel Rule creation, ahead of signing. The VASP-attributed suffix `5A50AB1E` opens the Travel Rule gate but does not auto-resolve -- call `payouts/:id/simulate/counterparty-webhook` manually. Distinct from the chain-confirm autopilot (5s after cosign approved). |
| Chain finality                                   | Real chain confirmation                                                                                                                            | Auto-resolves about 5s after the signing gate clears (chain-confirm autopilot); `POST /v2/sandbox/payouts/:id/simulate/confirm` to override (custom `txHash`) or simulate `outcome: "failed"`                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| Fiat settlement                                  | Real rail settlement                                                                                                                               | `POST /v2/sandbox/payouts/:id/simulate/settled`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

## Full happy path: non-custodial wallet (single-signer cosign)

This flow covers a non-custodial wallet with a signing threshold of 1: its roster still has the required minimum of two admins, but a single signer's stamp clears each payout. Provisioned via `POST /v2/customers/:id/wallets/claim-non-custodial`. For higher M-of-N thresholds, use the multi-signer flow below — `claim-non-custodial` is the only new-customer entry point either way.

Once the wallet is active, every payout from it is screened for compliance and Travel Rule first; only once it clears does the payout pause to collect the customer's signatures before it is finalized. In production a roster signer approves on the Conduit-hosted verify page; in sandbox you resolve the gate via a simulate endpoint.

<Warning>
  A non-custodial wallet+chain signs one payout at a time. Additional payouts
  queue and expose a positive `queuePosition` — read from `GET /v2/payouts/:id`,
  not the create response (it is populated as the payout enters the queue gate,
  just after the `202`). The queue is bounded — once full, `POST /v2/payouts`
  returns `422 PAYOUT_QUEUE_FULL`. Poll `GET /v2/payouts/:id` for
  `queuePosition`, or wait for the head payout to terminalize.
</Warning>

### Step 1 — Create the wallet

Once the customer has claimed non-custodial control via `POST /v2/customers/:customerId/wallets/claim-non-custodial` and the single roster member has enrolled (in sandbox, `POST /v2/sandbox/wallet-signers/{signerId}/mark-enrolled`), `POST /v2/customers/:customerId/wallets` with `{ "chain": "ethereum" }` returns `201 Created` and a wallet object with `id`, `address`, `chain`, `status: "active"`, and `custodyModel: "non_custodial"`. Capture `id` as `{{walletId}}`. Before the customer has claimed non-custodial control, the same call returns `422 WALLET_NO_PROVIDER_ACCOUNT`.

### Step 2 — Fund the wallet

Use `POST /v2/sandbox/customers/{customerId}/wallets/{walletId}/deposits/simulate` with `{ "assetAmount": { "code": "USDC", "chain": "ethereum", "amount": "100" } }`. Returns `202 Accepted` with `{ chain, txHash }`; the wallet balance updates within a couple of seconds.

### Step 3 — Create the payout

See the multi-signer flow below for the full `POST /v2/payouts` request body (USDC on ETHEREUM, crypto destination with `attestation.custody: "self"`, plus `documents` and `purpose`) — the request shape is identical between the two flows. The response carries `requiresUserSignature: true`:

```json theme={null}
{
  "id": "txn_...",
  "status": "pending",
  "requiresUserSignature": true
}
```

`queuePosition` is **not** in the create response. When the payout is queued behind earlier signing work on the same wallet+chain, it is populated as the payout enters the queue gate (just after the `202`) and is read from `GET /v2/payouts/:id`, where it is a positive integer (`1` is next in line). The head (active) payout — the one currently being signed — omits the field entirely.

`transaction.awaiting_signature` fires when the payout parks at the cosign gate, and the webhook payload carries `verificationUrl` + `expiresAt`. Subscribe to that topic to receive the verify URL — it is not part of the GET response shape. This applies to a wallet in the passkey signing mode; a wallet in a programmatic signing mode instead carries a `signingRequestId` on that webhook — see [Machine-signer stamping](/guides/machine-signer-stamping).

<Warning>
  **Queued behind an active payout.** When the wallet+chain already has a payout
  at the cosign gate, `POST /v2/payouts` still succeeds with a `202`; the queued
  payout then picks up a positive `queuePosition` (read it from `GET
      /v2/payouts/:id`, not the create response) and waits until the head payout
  terminalizes. If the queue is already at capacity, `POST /v2/payouts` returns
  `422 PAYOUT_QUEUE_FULL` instead. See [`PAYOUT_QUEUE_FULL`
  reference](/errors#payout-queue-full).
</Warning>

### Step 4 — Approve the document review

Because the payout carries `documents`, it parks for document review during compliance screening — **before** the customer is ever asked to sign. Clear it first via the sandbox simulate endpoint:

```bash theme={null}
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-approve \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
```

`200 OK`. Call `simulate-review-reject` instead to terminate the payout as `failed` with `failureCode: compliance_rejected` (reserved funds returned). Once compliance and Travel Rule clear, `transaction.processing` fires (status becomes `processing`) and the payout parks at the cosign gate — `transaction.awaiting_signature` fires with the `verificationUrl`.

### Step 5 — Resolve the cosign gate

The payout-keyed simulate endpoint drives the gate to a terminal cosign outcome. It produces the same effect as a real customer action on the verify page.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/cosign \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{ "outcome": "approved" }'
  ```
</CodeGroup>

`200 OK` returning the payout at its current state. `outcome` is `"approved"` or `"declined"`. Replays where the cosign gate already cleared but the payout is still active collapse to a no-op and return `200`. Calling this endpoint against a payout that has already reached a terminal state (`completed` or `failed`) returns `409 RESOURCE_TERMINAL`. After `approved`, the payout reaches `status: completed` automatically within about 5 seconds (chain-confirm autopilot); no follow-up `simulate/confirm` is required (the endpoint stays available to drive a custom `txHash` or the `outcome: "failed"` finality path).

<Note>
  **Force-fail frees the wallet immediately.** Force-failing a payout parked at the cosign gate (via `POST /v2/sandbox/transactions/:id/simulate/terminal { outcome: "failed" }`) frees the wallet at once: a follow-up payout on the same wallet is accepted within milliseconds, with no cooldown.
</Note>

## Full happy path: multi-signer non-custodial

When the wallet was provisioned via `POST /v2/customers/{id}/wallets/claim-non-custodial`, every payout pauses at an M-of-N quorum gate instead of a single-signer cosign gate. Each signer stamps independently; the payout auto-broadcasts when the threshold is met.

For the mental model see [Multi-signer wallets](/concepts/multi-signer-wallets). For threshold rules see [Signing thresholds](/concepts/signing-thresholds). For the copy-paste walkthrough see [Multi-signer wallets recipe](/sandbox/multi-signer-wallets#the-6-step-recipe).

### Step 1 — Provision the roster

Call `POST /v2/customers/{customerId}/wallets/claim-non-custodial` with a roster (admins + signers, each with a unique email and `credentialType: "passkey"`) and a `signingThreshold`. The endpoint returns 202 with a `claimId` and dispatches one `wallet_signer.invited` webhook per roster member.

### Step 2 — Enroll each signer

In production, signers visit the `verificationUrl` from their `wallet_signer.invited` payload and enroll a passkey. In sandbox, call `POST /v2/sandbox/wallet-signers/{signerId}/mark-enrolled` for each signer. Once the final signer is enrolled, `crypto_wallet.completed` fires and the wallet flips `active`.

### Step 3 — Fund the wallet

Inject a synthetic deposit via `POST /v2/sandbox/customers/{customerId}/wallets/{walletId}/deposits/simulate` with `{ "assetAmount": { "code": "USDC", "chain": "ethereum", "amount": "100" } }`. Returns `202 Accepted` with `{ chain, txHash }`; balance updates within a couple of seconds.

### Step 4 — Create the payout

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/payouts \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "customerId": "{{customerId}}",
      "assetAmount": {
        "code": "USDC",
        "chain": "ethereum",
        "amount": "10.000000"
      },
      "destination": {
        "type": "crypto",
        "recipient": {
          "rail": "crypto",
          "chain": "ethereum",
          "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "attestation": { "custody": "self" }
        }
      },
      "purpose": "treasury_management",
      "documents": ["{{docId}}"]
    }'
  ```

  ```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,
      assetAmount: { code: "USDC", chain: "ethereum", amount: "10.000000" },
      destination: {
        type: "crypto",
        recipient: {
          rail: "crypto",
          chain: "ethereum",
          address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          attestation: { custody: "self" },
        },
      },
      purpose: "treasury_management",
      documents: [docId],
    }),
  });
  const payout = 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,
          "assetAmount": {"code": "USDC", "chain": "ethereum", "amount": "10.000000"},
          "destination": {
              "type": "crypto",
              "recipient": {
                  "rail": "crypto",
                  "chain": "ethereum",
                  "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                  "attestation": {"custody": "self"},
              },
          },
          "purpose": "treasury_management",
          "documents": [doc_id],
      },
  )
  payout = r.json()
  ```
</CodeGroup>

`202 Accepted` with `{ "id": "txn_...", "status": "pending", "requiresUserSignature": true, ... }`. Capture the `id` as `{{payoutId}}`. The `documents` array must contain the `id` from a prior `POST /v2/documents` upload — payouts without supporting documents are rejected at creation with `422 DOCUMENTATION_REQUIRED`. Because this payout carries `documents`, it first parks for document review during the pre-signing compliance stage (Step 5). Only once that clears (and Travel Rule passes) does `transaction.processing` fire and the payout park at the quorum gate, at which point `transaction.awaiting_signature` fires with `verificationUrl` and `expiresAt`.

### Step 5 — Approve the document review

This payout carries `documents`, so it parks for document review during the pre-signing compliance stage — **before** the signers are asked to stamp. Clear it first:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-approve \
    -H "x-api-key: {{apiKey}}" \
    -H "Content-Type: application/json"
  ```
</CodeGroup>

`200 OK`. Call `simulate-review-reject` instead to terminate the payout as `failed` with `failureCode: compliance_rejected` (reserved funds returned). After approval, compliance + Travel Rule finish, `transaction.processing` fires, and the quorum gate opens.

### Step 6 — Collect stamps to quorum

Each stamp is one `POST /v2/sandbox/payouts/{payoutId}/simulate-stamp` call carrying the `walletSignerId` and an `outcome` (`approved` or `declined`, defaulting to `approved` when omitted). The response is the payout in its current state; quorum progress comes from the `transaction.signature_collected` webhook, whose payload carries the running `collected` and `required` counts:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-stamp \
    -H "x-api-key: {{apiKey}}" \
    -H "Content-Type: application/json" \
    -d '{ "walletSignerId": "{{signerId}}", "outcome": "approved" }'
  ```
</CodeGroup>

Each stamp re-fires `transaction.signature_collected`. Once `collected` reaches `required`, `transaction.quorum_met` fires and the payout proceeds to broadcast.

**Rejection.** A single `declined` outcome terminalizes the quorum and fails the payout with `failureCode: "user_signature_declined"`. The remaining signers cannot un-reject.

**Re-stamping.** Calling `simulate-stamp` twice with the same `walletSignerId` is idempotent: the second call does not double-count, and `transaction.signature_collected` reports the same `collected` count.

**Ghost-vote scrubbing.** If a signer is removed from the roster mid-payout (via `DELETE /v2/customers/:id/wallet-signers/:signerId`), their already-cast stamps are scrubbed from every in-flight payout for the customer. Affected payouts re-fire `transaction.signature_collected` with the new count; payouts that can no longer reach quorum on the new roster terminate with `failureCode: "roster_changed"`. See [Ghost-vote scrubbing](/sandbox/multi-signer-wallets#ghost-vote-scrubbing-signer-removed-mid-payout) for the walkthrough.

Once `collected` reaches `required`, `transaction.quorum_met` fires and the payout reaches `completed` within about 5 seconds (chain-confirm autopilot).

## Fiat withdrawal

A fiat withdrawal moves funds from a customer's virtual account to a bank account. The payout is submitted to the configured payment rail; in sandbox the rail call is mocked and `simulate/settled` drives the terminal outcome.

### Step 1 — Create the fiat payout

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/payouts \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "customerId": "{{customerId}}",
      "virtualAccountId": "{{virtualAccountId}}",
      "assetAmount": { "code": "USD", "amount": "25.00" },
      "destination": {
        "type": "fiat",
        "rail": "fedwire",
        "recipient": {
          "rail": "us",
          "type": "business",
          "legalName": "Acme Corp",
          "accountNumber": "1234594000000",
          "routingNumber": "021000021",
          "accountType": "checking",
          "bankAddress": {
            "addressLine1": "1 Bank Plaza",
            "city": "New York",
            "state": "NY",
            "postalCode": "10005",
            "country": "USA"
          },
          "phone": "+12125550100",
          "postalAddress": {
            "addressLine1": "10 Vendor St",
            "city": "New York",
            "state": "NY",
            "postalCode": "10005",
            "country": "USA"
          }
        }
      },
      "purpose": "treasury_management",
      "documents": ["{{docId}}"]
    }'
  ```

  ```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,
      assetAmount: { code: "USD", amount: "25.00" },
      destination: {
        type: "fiat",
        rail: "fedwire",
        recipient: {
          rail: "us",
          type: "business",
          legalName: "Acme Corp",
          accountNumber: "1234594000000",
          routingNumber: "021000021",
          accountType: "checking",
          bankAddress: {
            addressLine1: "1 Bank Plaza",
            city: "New York",
            state: "NY",
            postalCode: "10005",
            country: "USA",
          },
          phone: "+12125550100",
          postalAddress: {
            addressLine1: "10 Vendor St",
            city: "New York",
            state: "NY",
            postalCode: "10005",
            country: "USA",
          },
        },
      },
      purpose: "treasury_management",
      documents: [docId],
    }),
  });
  const payout = 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": virtual_account_id,
          "assetAmount": {"code": "USD", "amount": "25.00"},
          "destination": {
              "type": "fiat",
              "rail": "fedwire",
              "recipient": {
                  "rail": "us",
                  "type": "business",
                  "legalName": "Acme Corp",
                  "accountNumber": "1234594000000",
                  "routingNumber": "021000021",
                  "accountType": "checking",
                  "bankAddress": {
                      "addressLine1": "1 Bank Plaza",
                      "city": "New York",
                      "state": "NY",
                      "postalCode": "10005",
                      "country": "USA",
                  },
                  "phone": "+12125550100",
                  "postalAddress": {
                      "addressLine1": "10 Vendor St",
                      "city": "New York",
                      "state": "NY",
                      "postalCode": "10005",
                      "country": "USA",
                  },
              },
          },
          "purpose": "treasury_management",
          "documents": [doc_id],
      },
  )
  payout = r.json()
  ```
</CodeGroup>

`202 Accepted` with `{ "id": "txn_...", "status": "pending", ... }`. Capture the `id` as `{{payoutId}}`. Compliance screening runs automatically; in sandbox it resolves based on the account-number suffix (see catalog below). The accountNumber `1234594000000` ends in the last 8 digits `94000000` which is the happy-path suffix. Because the payout carries `documents`, it then parks for document review (`status: "processing"`) before settlement — clear the gate in the next step.

<Note>
  **`purpose` and `documents` are required.** Every payout except `purpose: intercompany` and, by default, `purpose: prefunding` must include at least one `documents` entry (documentation policy can still require documents on a `prefunding` payout above a configured amount — handle `422 DOCUMENTATION_REQUIRED` there too). Upload a supporting document first (`POST /v2/documents`) and pass its `id` in the `documents` array. `intercompany` payouts use a registered whitelist recipient instead of a document — see [Whitelist Recipients](/concepts/whitelist-recipients).
</Note>

### Step 2 — Approve the document review (sandbox only)

A fiat payout that carries `documents` parks for document review before it is submitted to the rail, exactly like the crypto flow. Approve it to let settlement proceed:

```bash theme={null}
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-approve \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
```

`200 OK` returning the payout at its current state. Call `simulate-review-reject` instead to terminate the payout as `failed` with `failureCode: compliance_rejected`; `transaction.rejected` fires and the reserved funds return to the virtual account.

### Step 3 — Drive settlement

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/settled \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "outcome": "completed",
      "utr": "IMAD-20260115-001"
    }'
  ```

  ```typescript typescript theme={null}
  const res = await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/payouts/${payoutId}/simulate/settled`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ outcome: "completed", utr: "IMAD-20260115-001" }),
    },
  );
  // returns the payout at its current state
  ```

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

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/sandbox/payouts/{payout_id}/simulate/settled",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={"outcome": "completed", "utr": "IMAD-20260115-001"},
  )
  # returns the payout at its current state
  ```
</CodeGroup>

`200 OK` returning the payout at its current state. The `outcome` field is `"completed"` or `"failed"`. `utr` is required when `outcome` is `"completed"` — supply a non-empty bank reference (IMAD, UETR, ACH trace number, or instant-payment reference). The payout transitions to `status: "completed"`, `completedAt` is populated, and `transaction.completed` fires carrying that reference on the destination's typed wire-reference field for the rail (e.g. `external_bank.fedwireImad`, `external_bank.swiftUetr`).

To drive a failure instead, use `outcome: "failed"` with an optional `reason` string:

```bash theme={null}
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/settled \
  -H "x-api-key: {{apiKey}}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "failed", "reason": "INSUFFICIENT_FUNDS" }'
```

`transaction.failed` fires with [`RAIL_UNAVAILABLE`](/errors) or the failure code from the rail mock.

### Fiat account-number suffix catalog

The sandbox reads the **last 8 digits** of `recipient.accountNumber` (all non-digit characters stripped before matching) to select a deterministic outcome. Set the suffix at account-creation time by choosing an accountNumber whose tail matches the desired scenario.

| Last 8 digits | Scenario                                                                                                                                                                                                                           | failureCode                               | Webhook                 |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------- |
| `94000000`    | Happy path — settlement completes                                                                                                                                                                                                  | —                                         | `transaction.completed` |
| `94009001`    | Rail policy rejects (amount limit, frequency cap, or recipient restriction)                                                                                                                                                        | [`RAIL_POLICY_REJECTED`](/errors)         | `transaction.failed`    |
| `94009002`    | Insufficient funds at settlement (funds were available at reservation)                                                                                                                                                             | [`INSUFFICIENT_FUNDS_AT_SETTLE`](/errors) | `transaction.failed`    |
| `94009003`    | No viable rail available for the corridor                                                                                                                                                                                          | [`RAIL_UNAVAILABLE`](/errors)             | `transaction.failed`    |
| `94009004`    | Rail provider timeout (internal `PROVIDER_TIMEOUT` distinction preserved on internal logs; the public surface emits the same [`RAIL_UNAVAILABLE`](/errors) by design — integration retry semantics are identical for either cause) | [`RAIL_UNAVAILABLE`](/errors)             | `transaction.failed`    |

Accounts not matching any documented suffix take the happy path (`94000000` behavior).

<Note>
  Two separate mechanisms drive fiat settlement outcomes:

  * **Account-number suffix** (`94009001`–`94009004`): locks the outcome at payout-creation time. Once the payout reaches the settlement step the mock fires automatically — you do not need to call `simulate/settled`.
  * **`simulate/settled { outcome: "failed" }`**: forces a failure on demand for any payout, independent of the account-number suffix. Use this when you want to drive a failure without embedding a magic suffix in the account number (for example, when testing retry logic against an existing account).

  For the happy path, use `simulate/settled { outcome: "completed", utr: "..." }` to supply the bank reference yourself.
</Note>

## Failure paths

Fiat payouts can also be failed with `POST /v2/sandbox/transactions/:id/simulate/terminal` with `{ "outcome": "failed" }` after payout acceptance. The same endpoint with `{ "outcome": "completed", "utr": "..." }` requires a settlement-ready payout; calling it before the API has selected a settlement route returns 422 [`SANDBOX_TRANSACTION_NOT_FORCE_TERMINAL_READY`](/errors#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`](/errors) (no on-chain broadcast). Once compliance has rejected the payout, `{ "outcome": "approve" }` is not available — a rejected case can only be terminalized via `reject`. Full catalog: [Withdrawal failure paths](/sandbox/withdrawals#failure-paths).

### Document-review reject

A payout that carries `documents` parks for document review. Call `POST /v2/sandbox/payouts/:id/simulate-review-reject` (no body) to reject it:

```bash theme={null}
curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate-review-reject \
  -H "x-api-key: {{apiKey}}" \
  -H "Content-Type: application/json"
```

`200 OK` returning the payout at its current state. The payout terminates as `status: "failed"` with `failureCode: compliance_rejected`, `transaction.rejected` fires (not `transaction.failed`), and the reserved funds return to the source balance. Returns `404 PAYOUT_NOT_FOUND` if the payout is not awaiting document review. To re-attempt, upload an acceptable document and submit a new payout with a fresh `idempotency-key`.

### Travel Rule paths

Use a VASP-attributed destination (suffix `5A50AB1E`) to route the payout through the Travel Rule flow. Drive the counterparty leg either with auto-pilot suffixes (`AC6BC0DE`, `ACCEEDED`, `BAD6A1A4`, `DEC11A1D`) or pre-empt the 10-second timer by calling `POST /v2/sandbox/payouts/:id/simulate/counterparty-webhook` with `{ "outcome": "acknowledged" | "approved" | "rejected" | "declined" }`. `rejected` and `declined` always terminate the payout as `status: "failed"` with `failureCode: travel_rule_rejected` — sandbox handles the signal regardless of whether the broadcast has happened (deterministic outcome the dashboard can render). Full catalog (including pre-broadcast Travel Rule failures): [Travel Rule scenarios](/sandbox/travel-rule-scenarios).

### Broadcast finality fail

To terminate from the post-broadcast side, call `simulate/confirm` with `outcome: "failed"`:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/confirm \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{ "outcome": "failed", "reason": "chain broadcast failed" }'
  ```
</CodeGroup>

`200 OK` returning the payout at its current state. The payout transitions to `status: "failed"`; `transaction.failed` fires. For the pre-broadcast variant (the payout rejects before any chain broadcast happens, no `txHash` is assigned, the reserved balance returns to available), send to a destination ending in suffix `BAD8CA57`. The full chain endpoint reference and suffix catalog is in [Withdrawal failure paths + chain reference](/sandbox/withdrawals#simulate-broadcast-failure-pre-broadcast-arm).

### Simulate broadcast failure (pre-broadcast arm)

`POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{payoutId}/simulate/broadcast-fail` arms the mock chain provider so that the **next** broadcast attempt for this payout throws a pre-broadcast error. The handler does not directly fire `transaction.failed`; instead it parks the payout via the standard pre-broadcast compensation path. That compensation produces a `transaction.failed` event. No `txHash` is assigned and the reserved balance returns to available.

Use this when you want to test the broadcast-failure path for a specific in-flight payout without relying on the address-suffix mechanism.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/{{payoutId}}/simulate/broadcast-fail \
    -H "x-api-key: {{apiKey}}" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{ "reason": "Simulated broadcast rejection" }'
  ```

  ```typescript typescript theme={null}
  const res = await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/payouts/${payoutId}/simulate/broadcast-fail`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY!,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ reason: "Simulated broadcast rejection" }),
    },
  );
  // returns the payout at its current state
  ```

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

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/sandbox/payouts/{payout_id}/simulate/broadcast-fail",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
          "Content-Type": "application/json",
      },
      json={"reason": "Simulated broadcast rejection"},
  )
  # returns the payout at its current state
  ```
</CodeGroup>

`200 OK` returning the payout at its current state. `reason` is the only accepted body field (optional, 1 to 500 chars). The body uses `.strict()` validation; any other key (including `code`) returns `400 VALIDATION_ERROR`. Returns `404 PAYOUT_NOT_FOUND` if the payout is not a crypto withdrawal or does not belong to your organization.

The compensation path raises `transaction.failed`. The public webhook fires with `failureCode: "provider_rejected"` and the supplied `reason` on `failureMessage` (the sandbox scenario tag is stripped before publishing). The same code/message land on the polled `GET /v2/transactions/:id` response per the [`failureMessage` symmetry contract](/webhooks#failuremessage-symmetry-contract). Live broadcasts can still surface `failureCode: null` when an unstructured chain-provider rejection lands; that's the live-only path, not this sandbox lever.

## Cancelling a payout

Sandbox uses the production cancel contract — see [`POST /v2/payouts/:id/cancel`](/guides/send-payout#cancel-a-payout) for state-based rules and error codes.

Two sandbox-specific deltas:

* The reliably scriptable cancel window is the non-custodial cosign gate; fiat and custodial-crypto payouts hand off inline (same as production), so their cancel window collapses to a narrow pre-handoff race that you usually can't observe from a test.
* There is no "force-cancel" simulate endpoint. To drive `failed` on fiat or custodial-crypto in sandbox, use the failure-suffix protocol (see [Failure paths](#failure-paths)) or `POST /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 or `POST /v2/webhooks/endpoints` exactly like production.

| Event                                     | When it fires                                                                                                                                                                                                                                                                                   |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transaction.created`                     | Immediately after `POST /v2/payouts` is accepted                                                                                                                                                                                                                                                |
| `transaction.awaiting_signature`          | Payout parks at the cosign gate (non-custodial only)                                                                                                                                                                                                                                            |
| `transaction.awaiting_sender_information` | Travel Rule path needs sender info before the counterparty leg can resolve                                                                                                                                                                                                                      |
| `transaction.completed`                   | Payout reaches terminal `completed` state                                                                                                                                                                                                                                                       |
| `transaction.failed`                      | Payout reaches terminal `failed` state (compliance reject, Travel Rule reject, cosign decline / timeout, broadcast fail, fiat rail failure)                                                                                                                                                     |
| `transaction.rejected`                    | Payout is rejected at the document-review gate (`simulate-review-reject`). Payload carries `reasonCategory: "document_inadequate"` and the accepted document types; reserved funds are returned. Polled `GET /v2/payouts/:id` shows `status: "failed"` with `failureCode: compliance_rejected`. |
| `transaction.cancelled`                   | Payout reaches terminal `cancelled` state via `POST /v2/payouts/{id}/cancel`. Payload carries `cancellationReason: "client_cancelled"` and `cancelledAt`; no `failureCode`/`failureMessage`.                                                                                                    |

`transaction.failed` payloads carry a `failureCode` when the cause is something your integration can act on — for example [`USER_SIGNATURE_TIMEOUT`](/errors), [`USER_SIGNATURE_DECLINED`](/errors), [`COMPLIANCE_REVIEW_REJECTED`](/errors), [`INSUFFICIENT_FUNDS_AT_SETTLE`](/errors), [`RAIL_POLICY_REJECTED`](/errors), [`RAIL_UNAVAILABLE`](/errors), [`TRAVEL_RULE_REJECTED`](/errors). See the [Webhooks reference](/webhooks) for full payload schemas.

See the [`failureMessage` symmetry contract](/webhooks#failuremessage-symmetry-contract) on the webhooks reference for the exact rules across the DB row, polled GET, and webhook payload.

## Address-suffix matching rules

* **EVM (Ethereum / Base / Polygon):** last 8 hex characters of the address, case-insensitive. `0x...DEADBEEF` matches suffix `DEADBEEF`.
* **Tron:** last 8 Base58 characters, case-sensitive.
* **Solana:** last 8 Base58 characters, case-sensitive.
* **Fiat (bank account):** last 8 digits of `recipient.accountNumber`, all non-digit characters stripped before matching.

The address must be valid for its chain; sandbox does not bypass on-chain address validation. Addresses not matching any documented suffix take the happy path (compliance `APPROVED`, `SELF_HOSTED` Travel Rule resolution, proceed to mocked broadcast).

## Consolidated withdrawal address-suffix catalog

Every suffix that is meaningful on a withdrawal destination, sourced from `scenario-suffixes.ts`. Suffixes match the **last 8 characters** of the destination address (lowercased hex for EVM; Base58 verbatim for Tron and Solana).

For scenario-specific detail, see the [consolidated withdrawal address-suffix catalog](/sandbox/withdrawals#consolidated-withdrawal-address-suffix-catalog), the [Wallet screening catalog](/sandbox/travel-rule-scenarios#wallet-screening-scenario-catalog), and the [Counterparty-outcome catalog](/sandbox/travel-rule-scenarios#counterparty-outcome-scenario-catalog-vasp-attributed-only). The full programmatic list is the [Scenario suffix table](/sandbox/cheat-sheet#scenario-suffixes).

| Suffix     | Class                                 | Outcome                                                                                                                                                                               | Auto-pilot?                           |
| ---------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `5A4D4EAA` | compliance approved                   | Happy path; proceeds to broadcast.                                                                                                                                                    | n/a                                   |
| `5A4D4EE5` | compliance rejected                   | `transaction.failed` with `compliance_review_rejected`.                                                                                                                               | n/a                                   |
| `5A4D4E5A` | compliance sanctions match            | `transaction.failed` with `compliance_review_rejected` (sanctions classification recorded for audit).                                                                                 | n/a                                   |
| `5A4D4E51` | compliance elevated risk              | Routes approved at current threshold; no observable difference.                                                                                                                       | n/a                                   |
| `5A4D4E9E` | compliance review pending             | Withdrawal pauses for compliance review; `status: pending`. Resolve via `POST /v2/sandbox/transactions/:id/simulate/compliance-decision` (`approve` → proceeds; `reject` → rejected). | n/a                                   |
| `5A50AB1E` | VASP wallet                           | Opens Travel Rule row at SENT; counterparty leg must be driven manually.                                                                                                              | No                                    |
| `5E1F0577` | Self-hosted wallet                    | No Travel Rule transfer; proceeds to broadcast.                                                                                                                                       | n/a                                   |
| `12517C00` | Elevated risk (wallet screening)      | Self-hosted resolution; the payout proceeds.                                                                                                                                          | n/a                                   |
| `5A4070ED` | Sanctions match (wallet screening)    | Payout terminates with `compliance_review_rejected` (sanctions classification recorded for audit).                                                                                    | n/a                                   |
| `AC6BC0DE` | Counterparty ACK                      | Informational ACK; the payout proceeds.                                                                                                                                               | Yes, 10 s after create.               |
| `ACCEEDED` | Counterparty accepted                 | Counterparty approves; the payout proceeds.                                                                                                                                           | Yes, 10 s.                            |
| `BAD6A1A4` | Counterparty rejected                 | Counterparty rejects (pre-broadcast on non-custodial / post-broadcast on custodial).                                                                                                  | Yes, 10 s.                            |
| `DEC11A1D` | Counterparty declined                 | Counterparty declines (same pre/post-broadcast behavior as rejected).                                                                                                                 | Yes, 10 s.                            |
| `BAD7E517` | Travel Rule validation rejection      | Unconditional pre-broadcast Travel Rule reject.                                                                                                                                       | No (deterministic at `/tx/validate`). |
| `D15CCAD0` | Travel Rule discrepancy               | Customer attested the destination is their own wallet but wallet screening identifies a VASP; payout fails with `travel_rule_rejected`.                                               | No                                    |
| `503CA110` | Travel Rule provider unavailable      | Retryable Travel Rule provider failure; retries exhaust; payout parks in `status: processing`.                                                                                        | No                                    |
| `5A4ED0DD` | Travel Rule non-sendable after create | Travel Rule row persisted in non-sendable state after `/tx/create`; pre-broadcast fail with `travel_rule_rejected`.                                                                   | No                                    |
| `DA171465` | Travel Rule waiting for information   | Row starts at WAITING\_FOR\_INFORMATION; broadcast proceeds; row advances to ACCEPTED via auto-pilot post-broadcast.                                                                  | Yes, 10 s.                            |
| `BAD8CA57` | Chain broadcast failure               | Pre-broadcast chain provider rejection; reserved balance released; `failureCode: provider_rejected`.                                                                                  | n/a                                   |

| Fiat suffix (last 8 digits of `recipient.accountNumber`) | Outcome                                                                |
| -------------------------------------------------------- | ---------------------------------------------------------------------- |
| `94000000`                                               | Happy path; settlement completes.                                      |
| `94009001`                                               | `rail_policy_rejected`.                                                |
| `94009002`                                               | `insufficient_funds_at_settle`.                                        |
| `94009003`                                               | `rail_unavailable`.                                                    |
| `94009004`                                               | `rail_unavailable` (provider timeout; same public code as `94009003`). |

## See also

* [Sandbox overview](/sandbox/overview)
* [Webhooks reference](/webhooks)
* [Error codes](/errors)
* [Deposits in sandbox](/sandbox/deposits)
* [Withdrawal failure paths](/sandbox/withdrawals#failure-paths)
* [Travel Rule scenarios](/sandbox/travel-rule-scenarios)
* [Withdrawal failure paths + chain reference](/sandbox/withdrawals#simulate-broadcast-failure-pre-broadcast-arm)
* [Non-Custodial Wallets](/concepts/non-custodial-wallets)
