Non-custodial means the customer holds the signing material. A payout cannot
broadcast until enough of the customer’s signers approve it on the
Conduit-hosted verify page — there is no code path in which Conduit moves the
funds alone. See Non-Custodial Wallets.
The lifecycle at a glance
| Phase | You hold | You call | You hear | Read more |
|---|---|---|---|---|
| 1. Provision signers | (no wallet yet) | POST /v2/customers/:id/wallets/claim-non-custodial | wallet_signer.invited, wallet_signer.added | Multi-signer wallets |
| 2. Enroll the roster | signers PENDING_ACTIVATION | (signers enroll out-of-band) | wallet_signer.enrolled, then crypto_wallet.completed | Multi-signer wallets |
| 3. Set the threshold | wallet ACTIVE | PUT /v2/customers/:id/signing-quorum | — | Signing thresholds |
| 4. Whitelist the destination | wallet ACTIVE | POST /v2/customers/:id/wallets/registered-addresses | — (synchronous) | Registered Addresses |
| 5. Initiate the payout | payout pending | POST /v2/payouts | transaction.created, transaction.awaiting_user_signature | Non-Custodial Wallets |
| 6. Collect signatures | payout pending | (signers approve on the verify page) | transaction.signature_collected, transaction.quorum_met | Multi-signer wallets |
| 7. Settle | payout pending → completed | — | transaction.completed | Non-Custodial Wallets |
status only ever takes the values pending, processing, completed, failed, and cancelled. The signing handshake happens while status is pending. The requiresUserSignature flag — not the status — tells you a signature is outstanding.
Step 1 — Provision the signers
State: the customer has no non-custodial wallet yet. Advance: callPOST /v2/customers/:customerId/wallets/claim-non-custodial with the roster (each signer’s email and role) and the signingThreshold. It returns 202 Accepted with a claimId. Provisioning runs in the background. Do not poll the claimId — it is a correlation receipt, not a status handle; you wait for the webhooks (wallet_signer.*, then crypto_wallet.completed).
Webhooks: for each roster member, a pair fires together — wallet_signer.added (the steady-state row, status PENDING_ACTIVATION) and wallet_signer.invited, which carries the per-signer verificationUrl and expiresAt. Send each invited signer’s URL to that person out of band.
What can go wrong: the claim is rejected synchronously when the roster or threshold is malformed:
| Code | Meaning |
|---|---|
CUSTOMER_KYB_INCOMPLETE | The customer is not yet KYB-approved. |
CUSTOMER_ALREADY_NON_CUSTODIAL / CUSTOMER_ALREADY_CUSTODIAL | The customer already has a wallet from a previous claim or the legacy custodial feature. |
ROSTER_BELOW_MIN_ADMINS | Fewer than the minimum two admins on the roster. |
THRESHOLD_EXCEEDS_ROSTER | signingThreshold is greater than the number of signers. |
SIGNER_EMAIL_DUPLICATE | Two roster members share an email. |
Step 2 — Enroll the roster
State: each signer isPENDING_ACTIVATION. The wallet is not yet usable.
Advance: no API call for you here — each signer opens their verificationUrl and registers a passkey before expiresAt. Do not poll. Wait for the webhooks.
Webhooks: wallet_signer.enrolled fires as each signer completes enrollment and flips to ACTIVE. Once every roster member is enrolled, the wallet becomes usable and crypto_wallet.completed fires carrying the wallet IDs — your signal the customer can now receive and send funds. The wallet only becomes ACTIVE after every invited member enrolls — independent of the signing threshold. If one invited signer never enrolls, the wallet never activates, even if the threshold could otherwise be met.
What can go wrong: an invitation not used before expiresAt auto-expires; re-invite the signer with POST /v2/customers/:customerId/wallet-signers. Adding, removing, promoting, or demoting a signer runs through a sequential ceremony. A second roster change while one is in flight returns 409 CEREMONY_IN_FLIGHT; retry after a short backoff.
Step 3 — Set or adjust the signing threshold
State: walletACTIVE.
Advance: the threshold you set at claim time governs every payout. To change it, call PUT /v2/customers/:customerId/signing-quorum with the new threshold. To require more signatures on one high-value wallet, set a per-wallet override with PUT /v2/wallets/:walletId/signing-quorum (an override may only lower the threshold relative to the customer default). Read the current value at GET /v2/customers/:customerId/signing-quorum.
Webhooks: none — the threshold is configuration, not a payout event.
What can go wrong:
| Code | Meaning |
|---|---|
QUORUM_THRESHOLD_EXCEEDS_SIGNERS | The requested threshold is greater than the active signer count. |
QUORUM_WALLET_OVERRIDE_CANNOT_RAISE | A per-wallet override tried to raise the threshold above the customer default. |
Step 4 — Whitelist the destination (intercompany payouts only)
State: walletACTIVE. This step applies only to payouts you send with purpose: INTERCOMPANY. For any other purpose, skip it — the inline recipient is accepted without a prior whitelist entry.
Advance: call POST /v2/customers/:customerId/wallets/registered-addresses with the destination chain and address. Registration is synchronous: a successful POST returns the address already in REGISTERED status. An INTERCOMPANY payout then matches its destination.recipient.address against the registered entry by chain and address.
Webhooks: none for crypto registered addresses — they register synchronously.
What can go wrong: registration can be screened and suspended on the spot, returning 409 REGISTERED_ADDRESS_SUSPENDED (do not retry; contact Conduit). The failure most integrators hit comes later, at payout time: an INTERCOMPANY payout to an address with no REGISTERED entry returns 422 — see RECIPIENT_NOT_WHITELISTED. See Registered Addresses for the custody-type discriminator and Travel Rule disclosure fields — the Travel Rule being the requirement to exchange originator and beneficiary information on transfers.
Step 5 — Initiate the payout
State: about to create the payout. Advance: callPOST /v2/payouts (requires the idempotency-key header). It returns 202 Accepted with the payout in status: "pending" and its id. Because the source wallet is non-custodial, the response also carries requiresUserSignature: true — your frontend can prepare to route signers at once, without waiting for the webhook. See the Payouts reference for the full request body and fields.
Webhooks: transaction.created fires on initiation. Compliance and Travel Rule checks then run in the background with no client action; the customer is never asked to sign a payout that has not passed screening. When screening clears, transaction.awaiting_user_signature fires once for the payout, carrying the shared verificationUrl, expiresAt, and requiredApprovals.
What can go wrong (synchronously, on the POST):
| Code | Playbook |
|---|---|
INVALID_ADDRESS_FORMAT | Invalid address format |
RECIPIENT_NOT_WHITELISTED | Recipient not whitelisted |
CONCURRENT_COSIGN_IN_FLIGHT | Concurrent cosign in flight — the same wallet already has a payout awaiting signature on the same chain |
INSUFFICIENT_FUNDS | The wallet’s available balance cannot cover the payout. |
CONCURRENT_COSIGN_IN_FLIGHT is the sequencing trap: signing on a wallet+chain is single-file. If GET /v2/payouts/:id reports a non-zero queuePosition, an earlier payout is signing ahead of this one.
Step 6 — The signing handshake
State:status: "pending", requiresUserSignature: true. The payout is parked at the quorum gate.
Advance: route each signer to the verificationUrl from the transaction.awaiting_user_signature payload (the URL and expiresAt arrive only on the webhook — they are not on GET /v2/payouts/:id, so persist them). Each signer approves on the Conduit-hosted verify page with their passkey. They stamp independently until the threshold M is reached. For what the signer sees and what you host versus what Conduit hosts, see Non-Custodial Wallets → Verify Page.
Webhooks: transaction.signature_collected fires once per stamp, carrying collected and required — drive a progress UI off these. When collected >= required, transaction.quorum_met fires, Conduit applies its own signature, and the payout proceeds to broadcast.
What can go wrong:
Failure (failureCode on transaction.failed) | Playbook |
|---|---|
USER_SIGNATURE_TIMEOUT | User signature timeout — no signer approved before expiresAt (default 15 minutes) |
USER_SIGNATURE_DECLINED | User signature declined — a signer declined on the verify page |
USER_SIGNATURE_REJECTED_BY_PROVIDER | User signature rejected by provider — a passkey approval could not be accepted |
CRYPTO_WALLET_MISCONFIGURED | Crypto wallet misconfigured — the wallet cannot be signed for |
ROSTER_CHANGED | A signer was removed mid-flight and the payout can no longer reach quorum — re-submit against the current roster |
transaction.signature_collected re-fires with the lowered count. See ghost-vote scrubbing in Multi-signer wallets.
Step 7 — Settlement and terminal state
State: quorum met, both Conduit and the customer signatures applied, broadcast underway. Advance: nothing to do — wait for the chain to confirm. Webhooks:transaction.completed fires when the chain confirms; the on-chain settlement reference is on destination.external_crypto.txHash. The payout’s status is now completed and requiresUserSignature is false. This is the terminal success state.
A payout can instead reach a terminal failure (transaction.failed, status failed) for any of the Step 6 reasons, or terminal cancellation (transaction.cancelled, status cancelled) if you called POST /v2/payouts/:id/cancel before broadcast began. A cancellation is not a failure — it releases the reserved balance back to available and carries no failureCode. Once broadcast has begun, cancel returns 409 PAYOUT_NOT_CANCELLABLE.
Terminal-state summary
| Terminal status | Webhook | Funds moved? | Next step |
|---|---|---|---|
completed | transaction.completed | Yes — settled on-chain | Reconcile on txHash |
failed | transaction.failed | No | Branch on failureCode; submit a new payout |
cancelled | transaction.cancelled | No — reserved balance released | Re-submit when ready |