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.
Test this flow in sandbox. Drive it end-to-end with simulated money and deterministic controls — start with the sandbox quickstart, then withdrawal simulation for this flow, and the cheat sheet for every magic value and simulate endpoint.
The lifecycle at a glance
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: callPOST /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:
See Signing thresholds for how the threshold and minimum-admin constraints compose.
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 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: 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:
The full constraint model — M-of-N, the minimum-admin floor, and the one-way override rule — lives in Signing thresholds.
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 POST /v2/payouts reference for the full request body and fields, and the Send a 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):
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 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.
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:
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.
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 thetransaction.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.
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). 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.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 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:1
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.2
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.Anti-pattern: do not pre-mint the link
Do not callPOST /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.
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
For the full payload schema of every event named here, see the Webhooks reference.