Skip to main content

TL;DR

New customers are non-custodial. Every fresh customer 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 409 WALLET_CUSTODY_NOT_CLAIMED. The custodial path covered below applies only to legacy customers that were provisioned through the CRYPTO_WALLET application before the non-custodial gate. The two custody models differ in four observable ways: whether the payout parks at a cosign gate, when chain finality fires, when the in-flight 409 happens, 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.

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 cosign in-flight (non-custodial only)

POST /v2/payouts returns 409 CONCURRENT_COSIGN_IN_FLIGHT when the source wallet already has a non-custodial payout awaiting signature on the same chain. Recovery: resolve the prior payout with simulate/cosign first or wait for it to terminalize. Retry with exponential backoff starting at 2 seconds. See CONCURRENT_COSIGN_IN_FLIGHT playbook.

Pre-broadcast Travel Rule reject

A counterparty rejection arrives pre-broadcast only when the cosign gate is still open. Two reliable paths to a pre-broadcast TR-reject:
  • Non-custodial wallet, manual counterparty call: cosign-gate-parked payout + simulate/counterparty-webhook {outcome: "rejected", reason} BEFORE simulate/cosign.
  • Unconditional pre-broadcast suffix: destination ending bad7e517 (TR_TX_VALIDATE_REJECTED). No manual call needed.
See Travel Rule scenarios.

How to provision a non-custodial wallet

Once the customer is KYB-approved, 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 for the chains you ask for. After every roster member enrolls, POST /v2/customers/:id/wallets becomes usable for each chain you requested. See Sandbox quickstart Step 5 for the full walkthrough.

Lifecycle, side by side

State / eventCustodialNon-custodial
Create payout (POST /v2/payouts)202 { id: txn_..., status: pending }202 { id: txn_..., status: pending_cosign }
In-flight conflict (409)n/aCONCURRENT_COSIGN_IN_FLIGHT if a prior NC payout on the same wallet awaits signature
CosignAuto-resolves; no customer callPOST .../simulate/cosign {outcome: "approved" | "declined"}
Chain broadcastInternal; no customer callAfter cosign approved
Chain finalityCustomer 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 terminalPre-broadcast if simulate/counterparty-webhook called before simulate/cosign
Force-fail at broadcastPOST .../simulate/broadcast-failPOST .../simulate/broadcast-fail (same)
Reachable failure codesRAIL_POLICY_REJECTED, RAIL_UNAVAILABLE, INSUFFICIENT_FUNDS_AT_SETTLEAll custodial codes + USER_SIGNATURE_DECLINED, USER_SIGNATURE_TIMEOUT, USER_SIGNATURE_REJECTED_BY_PROVIDER, TRAVEL_RULE_REJECTED

Recipe pairs

Common pitfalls

  • In-flight 409 on non-custodial: CONCURRENT_COSIGN_IN_FLIGHT. Resolve or wait for the prior payout. See the playbook.
  • 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.
  • Pre/post-broadcast TR-reject timing: only pre-broadcast if you call simulate/counterparty-webhook BEFORE simulate/cosign. Once cosign is approved, the payout broadcasts and a counterparty-webhook reject becomes audit-only on the TR row.
  • 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 are worth handling explicitly:
  • WALLET_CUSTODY_NOT_CLAIMED (409) — returned from POST /v2/customers/:id/wallets when the customer is still custodial. Call claim-non-custodial first; for legacy custodial customers (provisioned through CRYPTO_WALLET before the non-custodial gate) submit POST /v2/customers/:id/features with { "type": "WALLET_CUSTODY_CONVERSION" } to convert.
  • 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). Use POST /v2/customers/:id/features with { "type": "WALLET_CUSTODY_CONVERSION" } to convert them; multi-signer roster claim on existing custodial wallets will land in a follow-up.

See also