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

# Non-Custodial Payout Lifecycle

> A non-custodial payout end to end: setting up signers and thresholds, the signing handshake, whitelist checks, settlement, and terminal state — with the state, the call, the webhook, and the failure at every step.

This guide follows a single payout from a non-custodial wallet through its whole life: who signs, what gates it passes, which webhook tells you where it is, and what to do when something goes wrong. Each step links down to the concept page that owns the detail and out to the error playbook for that step's failures.

Need the mental model for one piece — the roster, the threshold, the verify page? Start at [Multi-signer wallets](/concepts/multi-signer-wallets). Need the full payload schema for an endpoint? Follow the links to the reference. This page assumes your customer is already KYB-approved.

<Note>
  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](/concepts/non-custodial-wallets).
</Note>

<Note>
  **Test this flow in sandbox.** Drive it end-to-end with simulated money and deterministic controls — start with the [sandbox quickstart](/sandbox/quickstart), then [withdrawal simulation](/sandbox/withdrawals) for this flow, and the [cheat sheet](/sandbox/cheat-sheet) for every magic value and simulate endpoint.
</Note>

## 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](/concepts/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](/concepts/multi-signer-wallets)   |
| 3. Set the threshold         | wallet `active`                | `PUT /v2/customers/:id/signing-quorum`                | —                                                           | [Signing thresholds](/concepts/signing-thresholds)       |
| 4. Whitelist the destination | wallet `active`                | `POST /v2/customers/:id/wallets/registered-addresses` | — (synchronous)                                             | [Registered Addresses](/concepts/registered-addresses)   |
| 5. Initiate the payout       | payout `pending`               | `POST /v2/payouts`                                    | `transaction.created`, `transaction.awaiting_signature`     | [Non-Custodial Wallets](/concepts/non-custodial-wallets) |
| 6. Collect signatures        | payout `pending`               | (signers approve on the verify page)                  | `transaction.signature_collected`, `transaction.quorum_met` | [Multi-signer wallets](/concepts/multi-signer-wallets)   |
| 7. Settle                    | payout `pending` → `completed` | —                                                     | `transaction.completed`                                     | [Non-Custodial Wallets](/concepts/non-custodial-wallets) |

Payout `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:** call `POST /v2/customers/:customerId/wallets/claim-non-custodial` with the `roster` (each signer's email and role) and the `signingThreshold`. The optional `chains` array names which chains to provision; **omit it** to provision a wallet on every supported chain in one claim (recommended — a later chain then needs no re-authentication). The three EVM chains share one address and cost a single admin approval; `solana` and `tron` each get their own. 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.                                                       |

See [Signing thresholds](/concepts/signing-thresholds) for how the threshold and minimum-admin constraints compose.

## Step 2 — Enroll the roster

**State:** each signer is `pending_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 `claim.completed` fires carrying the `claimId` and the activated wallet IDs — your signal the customer can now receive and send funds (a customer-level `crypto_wallet.completed`, carrying `customerId` only, also fires). 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; create a new invitation for the signer with `POST /v2/customers/:customerId/wallet-signers` and send them the fresh `verificationUrl` yourself — Conduit does not re-notify the signer. 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:** wallet `active`.

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

The full constraint model — M-of-N, the minimum-admin floor, and the one-way override rule — lives in [Signing thresholds](/concepts/signing-thresholds).

## Step 4 — Whitelist the destination (intercompany payouts only)

**State:** wallet `active`. 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`](/errors/recipient-not-whitelisted). See [Registered Addresses](/concepts/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:** call `POST /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 [`POST /v2/payouts`](/api-reference/payouts/create-a-payout) reference for the full request body and fields, and the [Send a payout](/guides/send-payout) guide for the end-to-end lifecycle.

**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_signature` fires once per signing attempt (and re-fires with an incremented `attempt` whenever the request is rebuilt — see below), carrying the shared `verificationUrl`, `expiresAt`, and `requiredApprovals`.

**What can go wrong (synchronously, on the `POST`):**

| Code                        | Playbook                                                                                                                        |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_ADDRESS_FORMAT`    | [Invalid address format](/errors/invalid-address-format)                                                                        |
| `RECIPIENT_NOT_WHITELISTED` | [Recipient not whitelisted](/errors/recipient-not-whitelisted)                                                                  |
| `PAYOUT_QUEUE_FULL`         | [Payout queue full](/errors#payout-queue-full) — the wallet+chain queue is at capacity; wait for the head payout to terminalize |
| `INSUFFICIENT_FUNDS`        | The wallet's available balance cannot cover the payout.                                                                         |

`PAYOUT_QUEUE_FULL` means the wallet+chain queue is at capacity: signing on a wallet+chain is single-file, with additional payouts queued behind the active one. Poll `GET /v2/payouts/:id` for a non-zero `queuePosition` to track where a payout sits in line.

## 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` delivered on the `transaction.awaiting_signature` webhook — see [Getting the signing link](#getting-the-signing-link) for exactly where the link comes from, how to fetch it again if you missed the webhook, and the one pattern to avoid. 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](/concepts/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_expired`                        | [User signature expired](/errors#user-signature-expired) — no signer approved before the signing window closed on the final attempt; the window is the `expiresAt` on each `transaction.awaiting_signature` webhook (up to \~23h on EVM, \~12h on Tron — not a fixed 15 minutes), re-offered with a fresh link each attempt |
| `user_signature_timeout`                        | [User signature timeout](/errors/user-signature-timeout) — the payout timed out waiting in the wallet's signing queue (behind other payouts on the same wallet) before signing even started; submit a new payout once the wallet's earlier payouts finish                                                                   |
| `user_signature_declined`                       | [User signature declined](/errors/user-signature-declined) — a signer declined on the verify page                                                                                                                                                                                                                           |
| `user_signature_rejected_by_provider`           | [User signature rejected by provider](/errors/user-signature-rejected-by-provider) — a passkey approval could not be accepted                                                                                                                                                                                               |
| `crypto_wallet_misconfigured`                   | [Crypto wallet misconfigured](/errors/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                                                                                                                                                                                                            |

In every case **no funds move** and the payout is terminal — submit a new payout to retry. If a signer is removed while a payout sits at the gate, that signer's stamps are scrubbed and `transaction.signature_collected` re-fires with the lowered count. See ghost-vote scrubbing in [Multi-signer wallets](/concepts/multi-signer-wallets).

## Getting the signing link

A non-custodial payout has exactly one signing link — a single shared page your whole roster approves on. Here is where it comes from, how to recover it, and the mistake that made it look like links "expired instantly."

### Primary: the webhook

The link is delivered on the `transaction.awaiting_signature` webhook as `verificationUrl`, alongside `expiresAt` and `requiredApprovals`. This webhook fires once the payout clears compliance and reaches signature collection — not at `POST /v2/payouts`. Route every signer to that one URL.

<Note>
  This whole page describes a wallet in the **passkey** signing mode — human signers approve on the verify page, so `transaction.awaiting_signature` carries a `verificationUrl`. A wallet in a **programmatic** signing mode carries a `signingRequestId` on that webhook — a machine integration reads the request via `GET /v2/signing-requests/{signingRequestId}` and resolves it via `POST .../approve` or `.../reject` (see [Machine-signer stamping](/guides/machine-signer-stamping)). If a programmatic wallet's roster also has a human passkey signer, that webhook **additionally** carries a `verificationUrl`: the passkey signer may approve on the verify page alongside the machine signers, so the two can share one payout's quorum. A machine-only programmatic wallet carries no `verificationUrl`.
</Note>

If a signing window closes before quorum, Conduit rebuilds the request (fresh window, new `verificationUrl`) and re-fires `transaction.awaiting_signature` with an incremented `attempt`. **Always use the latest `attempt`'s URL and discard earlier ones.** See [`transaction.awaiting_signature`](/webhooks#transactionawaiting_signature) for the payload.

### Recovery: you missed the webhook or need the link again

Two ways to get the current link without re-running the payout:

<Steps>
  <Step title="Read (or redeliver) the webhook delivery">
    `GET /v2/webhooks/deliveries?eventType=transaction.awaiting_signature` and read `payload.verificationUrl` from the delivery for your transaction, or `POST /v2/webhooks/deliveries/:id/retry` to have Conduit resend it. See [Delivery lifecycle](/webhooks#delivery-lifecycle).
  </Step>

  <Step title="Ask for the current link by your own reference">
    `POST /v2/customers/:customerId/verifications` with `{ "type": "transaction_approval", "clientReferenceId": "<your payout id>" }`. When the payout is awaiting signature this returns the current live link (same URL the webhook carried) — for a passkey wallet, or a programmatic wallet whose roster has a human passkey signer; it never mints a second, competing link. A machine-only programmatic wallet has no human link and returns `409 SIGNING_LINK_NOT_HUMAN_SIGNABLE` — approve via the signing-requests API instead.
  </Step>
</Steps>

### Anti-pattern: do not pre-mint the link

Do **not** call `POST /v2/customers/:customerId/verifications` to *create* a signing link before the payout is awaiting signature. A payout spends its first seconds clearing compliance and document review; a link minted in that window is superseded the moment Conduit mints the canonical one, and the pre-minted link then reads as expired on the verify page. This is why an "instant expiry" can look like a TTL bug when it is really a superseded link.

If you call the endpoint before the payout reaches signature collection, it does not mint a doomed link — it returns `409 TRANSACTION_NOT_AWAITING_SIGNATURE`. Treat that as "not ready yet": wait for `transaction.awaiting_signature`, then use the webhook's URL (or the recovery calls above). See [Transaction Not Awaiting Signature](/errors#transaction-not-awaiting-signature).

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

For the full payload schema of every event named here, see the [Webhooks reference](/webhooks).
