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

# Custodial vs non-custodial

> Side-by-side mental model for the two crypto custody flows: how to provision a non-custodial wallet, what changes if you ever see a custodial one, common pitfalls.

## TL;DR

New customers are non-custodial by default. A fresh customer first enables crypto via `POST /v2/customers/:id/features { type: crypto_wallet }`, then claims non-custodial control of their wallet account through `POST /v2/customers/:id/wallets/claim-non-custodial` before any wallet address is issued. Calling `POST /v2/customers/:id/wallets` first returns `422 WALLET_NO_PROVIDER_ACCOUNT` pointing back at the claim. Calling claim-non-custodial before the `CRYPTO_WALLET` feature is approved returns `422 CRYPTO_FEATURE_NOT_APPROVED`. For orgs Conduit has configured for custodial wallets, that first `POST /v2/customers/:id/wallets` instead provisions a custodial account directly — no claim — and payouts skip the cosign gate. The two custody models differ in four observable ways: whether the payout parks at a cosign gate, when chain finality fires, how concurrent payouts are handled, and the Travel Rule pre-broadcast window.

## What changes between the two models

### Cosign gate

* **Custodial**: payouts auto-broadcast after compliance checks. No customer-side signature step.
* **Non-custodial**: payouts park at `pending_cosign` and require `POST /v2/sandbox/payouts/:id/simulate/cosign {outcome: "approved" | "declined"}` to drive forward.

<Note>
  The cosign column on this page describes the **passkey-required** signing mode (the sandbox default). A wallet in a **programmatic** signing mode parks the same way, but resolution is a machine stamp rather than a passkey: submit a real P-256 stamp via `POST /v2/signing-requests/{id}/approve` (real stamps verify in sandbox unmodified), or use the per-signer shortcut `POST /v2/sandbox/payouts/:id/simulate-stamp`. See [Programmatic payout signing](/guides/machine-signer-stamping) and the [multi-signer sandbox recipe](/sandbox/multi-signer-wallets).
</Note>

### Chain finality autopilot

* **Custodial**: the integrator drives chain confirmation explicitly with `POST /v2/sandbox/payouts/:id/simulate/confirm {outcome: "completed", txHash}`. Production: real chain confirmation.
* **Non-custodial**: after cosign approves, the chain-confirm autopilot fires within \~5s and drives the payout to `completed`. No `simulate/confirm` call needed.

### Concurrent payouts (non-custodial only)

A non-custodial wallet+chain runs one signing ceremony at a time — and that seat is shared across payouts **and** conversion/offramp source-leg approvals, so a conversion signing from the same wallet+chain queues behind an in-flight payout (and vice versa). Additional payouts are queued. `queuePosition` is **not** in the `POST /v2/payouts` response — it is populated as the payout enters the queue gate (just after the `202`) and is read via `GET /v2/payouts/:id`. The active/head payout omits the field. The queue is bounded: once full, `POST /v2/payouts` returns `422 PAYOUT_QUEUE_FULL`. Recovery: poll `GET /v2/payouts/:id` for `queuePosition`, or wait for the head payout to terminalize. See [`PAYOUT_QUEUE_FULL` reference](/errors#payout-queue-full).

### Pre-broadcast Travel Rule reject

A counterparty rejection arrives pre-broadcast as long as the payout has not yet broadcast on-chain. On the non-custodial flow Travel Rule runs before signing, so a counterparty reject is pre-broadcast — and terminates the payout with `TRAVEL_RULE_REJECTED` — throughout the signing window, right up to broadcast. It only becomes audit-only once the payout has broadcast. Two reliable paths to a pre-broadcast TR-reject:

* **Non-custodial wallet, manual counterparty call**: `simulate/counterparty-webhook {outcome: "rejected", reason}` once the Travel Rule row exists — it is created at the pre-signing Travel Rule step, so wait for `transaction.processing` (which fires right after) before calling; a call made earlier, during compliance screening, returns `404` (no row yet). From `transaction.processing` through broadcast the reject terminates the payout with `TRAVEL_RULE_REJECTED`, whether the signers have stamped yet or not.
* **Unconditional pre-broadcast suffix**: destination ending `bad7e517` (TR\_TX\_VALIDATE\_REJECTED). No manual call needed, and no quorum timing to track.

See [Travel Rule scenarios](/sandbox/travel-rule-scenarios#how-to-guarantee-a-pre-broadcast-terminal-rejection).

## How to provision a non-custodial wallet

Once the customer is KYB-approved AND has an approved `CRYPTO_WALLET` feature on file (submit it via `POST /v2/customers/:id/features { type: crypto_wallet }` — sandbox-default auto-approve; live-default manual review), call `POST /v2/customers/:id/wallets/claim-non-custodial` with a `roster` (admins + signers) and a `signingThreshold`. The claim endpoint provisions the underlying wallet account, mints the multi-signer roster, and writes the wallets — on every supported chain by default, or just the ones named in an optional `chains` array. The three EVM chains share one address and one admin approval. After every roster member enrolls, `POST /v2/customers/:id/wallets` can add a wallet on any chain you excluded. The valid roster shapes depend on the customer's configured signing mode — passkey-only by default, machine (`api_key`) signers in the programmatic modes; see [Add a crypto wallet](/guides/add-crypto-wallet). See [Sandbox quickstart](/sandbox/quickstart) Step 5 for the full walkthrough.

## Lifecycle, side by side

| State / event                      | Custodial                                                                        | Non-custodial                                                                                                                                                                                                   |
| ---------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create payout (`POST /v2/payouts`) | `202 { id: txn_..., status: pending }`                                           | `202 { id: txn_..., status: pending_cosign }`                                                                                                                                                                   |
| Concurrent payouts                 | n/a                                                                              | Additional payouts queue behind the active one (read `queuePosition > 0` from `GET /v2/payouts/:id`, not the create response); queue full returns `422 PAYOUT_QUEUE_FULL`                                       |
| Cosign                             | Auto-resolves; no customer call                                                  | `POST .../simulate/cosign {outcome: "approved" \| "declined"}`                                                                                                                                                  |
| Chain broadcast                    | Internal; no customer call                                                       | After cosign approved                                                                                                                                                                                           |
| Chain finality                     | Customer calls `POST .../simulate/confirm {outcome: "completed", txHash: "..."}` | Auto-resolves in \~5s via the chain-confirm autopilot                                                                                                                                                           |
| Travel Rule reject (counterparty)  | Post-broadcast audit-only; payout already terminal                               | Pre-broadcast (terminates `travel_rule_rejected`) any time before the payout broadcasts — Travel Rule runs before signing, so a reject terminates throughout the signing window; audit-only only once broadcast |
| Force-fail at broadcast            | `POST .../simulate/broadcast-fail`                                               | `POST .../simulate/broadcast-fail` (same)                                                                                                                                                                       |
| Reachable failure codes            | `rail_policy_rejected`, `rail_unavailable`, `insufficient_funds_at_settle`       | All custodial codes + `user_signature_declined`, `user_signature_timeout`, `user_signature_expired`, `user_signature_rejected_by_provider`, `travel_rule_rejected`                                              |

## Recipe pairs

* **Custodial USDC withdrawal** vs **non-custodial cosign approved/declined** in [Withdrawals](/sandbox/withdrawals#full-happy-path-non-custodial-wallet-cosign).
* **Fiat withdrawal** (no custody flavor) in [Withdrawals — Fiat withdrawal](/sandbox/withdrawals#fiat-withdrawal).
* **Non-custodial pre-broadcast TR-reject** in [Travel Rule scenarios](/sandbox/travel-rule-scenarios#how-to-guarantee-a-pre-broadcast-terminal-rejection).

## Common pitfalls

* **Queue full on non-custodial**: `PAYOUT_QUEUE_FULL` (422). The wallet+chain signing queue is at capacity — and it counts conversion/offramp source-leg approvals from the same wallet+chain, not just payouts. Wait for the head signing ceremony to terminalize, then retry. See [`PAYOUT_QUEUE_FULL` reference](/errors#payout-queue-full).
* **Mixed-case EVM addresses**: any mixed-case EVM destination address that is not a valid EIP-55 checksum returns `400 INVALID_ADDRESS_FORMAT`. Use all-lowercase. See [INVALID\_ADDRESS\_FORMAT](/errors/invalid-address-format).
* **Pre/post-broadcast TR-reject timing**: the cutoff is the on-chain broadcast, not cosign approval. Non-custodial Travel Rule runs before signing, so a `simulate/counterparty-webhook` reject is pre-broadcast (terminates `TRAVEL_RULE_REJECTED`) at any point in the signing window — before or after `simulate/cosign` is approved. It becomes audit-only on the TR row only once the payout has broadcast.
* **`txHash` constraint on `simulate/confirm`**: the `txHash` you pass to `simulate/confirm` does not need to be a real on-chain hash (sandbox is isolated from real chains), but it must be a hex-shaped 32-byte string (`0x` + 64 hex chars). Any arbitrary `0xdeadbeef...` of the right length works.

## Error handling on the claim endpoint

`POST /v2/customers/:customerId/wallets/claim-non-custodial` is the canonical entry point into the multi-signer non-custodial flow. Three 409s plus two 422s are worth handling explicitly:

* **`WALLET_NO_PROVIDER_ACCOUNT` (422)** — returned from `POST /v2/customers/:id/wallets` when the customer has no wallet account yet — the common fresh-customer case, before any claim. Claim non-custodial control first with `POST /v2/customers/{id}/wallets/claim-non-custodial`, then retry creating the wallet.
* **`WALLET_CUSTODY_NOT_CLAIMED` (409)** — returned from `POST /v2/customers/:id/wallets` only when the customer already has a *custodial* wallet account and the custodial path is not enabled for your organization. Fresh non-custodial customers never hit this; where the custodial path is enabled, the same call returns the wallet directly.
* **`CUSTOMER_ALREADY_NON_CUSTODIAL` (409)** — non-custodial control has already been claimed for this customer. The claim endpoint provisions only fresh customers. Use the roster-management endpoints (`POST/DELETE /v2/customers/:customerId/wallet-signers`, `POST .../promote`, `POST .../demote`) to modify signers instead.
* **`CUSTOMER_ALREADY_CUSTODIAL` (409)** — the customer is a legacy custodial customer (provisioned via the `CRYPTO_WALLET` application before the non-custodial gate). Multi-signer roster claim on existing custodial wallets will land in a follow-up; until then these customers stay on the custodial path.
* **`CRYPTO_FEATURE_NOT_APPROVED` (422)** — the customer has no active `CRYPTO_WALLET` feature row, or the feature application is still pending review. Submit `POST /v2/customers/:id/features { type: crypto_wallet }` and wait for `status: approved` before retrying. In sandbox the default is auto-approve; in live the default is manual review unless the org has opted out via the per-org `cryptoEnablementRequiresReview` setting.

## See also

* [Sandbox quickstart](/sandbox/quickstart) - end-to-end including the multi-signer `claim-non-custodial` flow
* [Withdrawals](/sandbox/withdrawals) - full state diagram and timing
* [Travel Rule scenarios](/sandbox/travel-rule-scenarios)
* [`PAYOUT_QUEUE_FULL` reference](/errors#payout-queue-full)
* [`INVALID_ADDRESS_FORMAT` playbook](/errors/invalid-address-format)
* [`WALLET_CUSTODY_NOT_CLAIMED` reference](/errors#wallet-custody-not-claimed)
