Overview
This page walks the multi-signer non-custodial flow as it works in sandbox: a customer claims non-custodial control, each signer enrolls a passkey, deposit addresses become available, and a payout collects a stamp from each required signer before broadcasting. The same six webhook topics fire in live — the only sandbox-specific behavior is auto-activation (described under Sandbox vs live caveats).Prerequisites
- The customer must be KYB-approved (
customer.activatedfired). - Your organization has a webhook endpoint registered and reachable. The whole flow is webhook-driven; without an endpoint you will never see step 2 onward.
Headless sandbox testing. The webhook-driven flow above assumes signers complete enrollment + payout approval through the verification portal. Two sandbox-only shortcut endpoints let you script the whole loop without a browser:These endpoints exist in sandbox only — they have no live counterpart.
POST /v2/sandbox/wallet-signers/:signerId/mark-enrolled— bypass passkey enrollment and mark a signer enrolled. Auto-activation fires once every roster member is marked.POST /v2/sandbox/payouts/:payoutId/simulate-stamp— stamp a payout on behalf of a signer without driving the approval page.
The 6-step recipe
Claim non-custodial control
POST /v2/customers/:customerId/wallets/claim-non-custodial with a roster, a signing threshold, and the chains you want wallets on.202 Accepted:Receive one `wallet_signer.invited` webhook per roster member
Each invited signer gets their own webhook with a per-user
verificationUrl. URLs are scoped to one signer and expire at expiresAt.Distribute the verification URLs out-of-band
Send each signer their own
verificationUrl through your product’s notification channel — email, Slack, in-app — whatever you use to reach end users. Conduit does not deliver these URLs to signers directly.Treat the URL like a magic-link credential: one signer, one URL, do not share across the roster.Receive one `wallet_signer.enrolled` webhook per signer
When a signer opens their URL and completes enrollment, you receive:Track these against the
walletSignerIds you saw in step 2 to know when the last signer has enrolled.Wallets become usable (sandbox auto-activation)
Once every roster member has enrolled, sandbox auto-activates the customer’s wallets. You receive one Deposit addresses are now available via
crypto_wallet.completed per chain you requested:GET /v2/customers/:customerId/wallets. The customer can receive funds and submit payouts.Submit a payout — stamps collect over webhook
POST /v2/payouts returns 202 { id: "txn_...", status: "pending_cosign" }. The payout then drives four event topics:1. transaction.awaiting_user_signature — fires once when the payout parks at the cosign gate. The verificationUrl is a single shared approval page; the roster members signed-in there each stamp the payout with their passkey.transaction.signature_collected — fires once per signer stamp. Drive a progress UI off collected / required.transaction.quorum_met — fires once when collected >= required. The compliance stamp runs next.transaction.completed (or transaction.failed) — fires once when the payout reaches a terminal state. In sandbox the chain-confirm autopilot resolves this within ~5 seconds of quorum_met. transaction.failed carries a failureCode your integration branches on — see Failure cases.How compliance works in sandbox. After customer signers reach the configured threshold on a payout, the sandbox automatically casts the
conduit_compliance stamp to simulate the production cosign service. No fintech action is required; the compliance pipeline auto-passes and the stamp fires immediately, so transaction.quorum_met is followed within milliseconds by transaction.completed. In live this stamp comes from a separate Conduit-internal service that runs real compliance checks (sanctions screening, travel rule) before stamping.Failure cases
A payout that does not complete firestransaction.failed with one of the codes below.
failureCode | Meaning | Retryable? |
|---|---|---|
USER_SIGNATURE_DECLINED | A signer rejected the payout from the approval page. | Yes — submit a new payout. |
USER_SIGNATURE_TIMEOUT | The signing window expired before quorum was reached. | Yes — submit a new payout. |
USER_SIGNATURE_REJECTED_BY_PROVIDER | A signer’s passkey approval could not be accepted. | Yes — submit a new payout. Contact support on repeat. |
ROSTER_CHANGED | A signer was removed (or moved out of the signing pool) mid-flight. Their stamp was scrubbed and the payout could not finish on the new roster. | Yes — re-initiate; the new attempt collects from the current roster. |
AML_REJECTED | The compliance stamp rejected the payout after quorum. No funds moved. | No — contact support. |
PROVIDER_REJECTED | The chain broadcast was rejected by the upstream RPC, or a sandbox scenario forced a reject. failureMessage carries the reason when surfaced. | Yes after adjusting inputs (destination, amount, rail). |
Roster lifecycle ceremonies
Once the 6-step recipe above ships a working multi-signer wallet, three lifecycle ceremonies on the roster itself become testable. All three are sandbox-runnable; the underlying root-quorum ceremony is identical to production.Add a fourth signer mid-life
POST /v2/customers/{customerId}/wallet-signers provisions a new signer on an active roster. The threshold is unchanged; only the eligible-stamper pool grows.
wallet_signer.invited(new signer; payload carriesverificationUrl)wallet_signer.added(co-emitted)wallet_signer.enrolled(aftermark-enrolled)
Promote a signer to admin, demote an admin to signer
POST /v2/customers/{customerId}/wallet-signers/{signerId}/promote and .../demote mutate a signer’s role. Each call runs a root-quorum ceremony in the background.
- Demoting back-to-back without waiting for the first ceremony to complete returns
409 CEREMONY_IN_FLIGHT. Retry after a short backoff. - Demoting an admin that would drop the admin count below the floor returns
403 WOULD_BREAK_MIN_ADMINS.
Ghost-vote scrubbing: signer removed mid-payout
DELETE /v2/customers/{customerId}/wallet-signers/{signerId} removes a signer. If the signer had already stamped an in-flight payout, the stamp is scrubbed from every affected payout. Payouts that can no longer reach quorum on the new roster terminate failed with failureCode: "ROSTER_CHANGED".
wallet_signer.promoted(signer D, replacement admin)wallet_signer.demoted(signer A)wallet_signer.removed(signer A)transaction.signature_collected(votesCollected: 0) — re-fires after the scrubtransaction.failedwithfailureCode: "ROSTER_CHANGED"— payout cannot reach quorum on the new roster
Sandbox vs live caveats
Phase 0 sandbox-only: this flow runs exclusively against
MockTurnkey. The matching live integration ships with C2-1539 (real Turnkey adapter) + C2-1542 (real activation ceremony). Live builds currently reject every new multi-signer method with a sandbox-only stub.- Wallet activation is automatic in sandbox, manual in live. When the last signer enrolls in sandbox,
crypto_wallet.completedfires immediately. In live, activation runs an additional governance ceremony before the wallet becomes usable. - Passkey enrollment uses synthetic credentials in sandbox. The sandbox verification flow goes through the same browser passkey UX as live — your test signers will see the same OS prompt — but the credentials it produces are sandbox-only and never authorize real funds.
- Chain confirmation autopilots in sandbox. After
quorum_met, sandbox resolvestransaction.completedwithin ~5 seconds without a real chain broadcast. Live waits for real chain finality. - No sandbox payout ever touches a real chain. Destination addresses, hashes, and signatures in sandbox are isolated from mainnet. A
txHashon a sandboxtransaction.completedis shape-valid hex but not lookupable on-chain.
What changes in live
The contract you integrate against is the same. The pieces underneath swap out:- The real custody provider replaces the sandbox mock (C2-1539).
- Real WebAuthn passkeys replace synthetic credentials. The headless
mark-enrolledendpoint has no live counterpart — signers must complete browser passkey enrollment. - The real compliance pipeline (sanctions screening + travel rule) replaces the auto-pass stamp. Some payouts will be rejected here that always passed in sandbox.
- A real activation ceremony replaces the sandbox auto-activate hook (C2-1542).
crypto_wallet.completeddoes not fire the instant the last signer enrolls. - The
queuePositionfield on signing-queue webhooks is populated by the real per-(wallet, chain) counter (C2-1543); in sandbox it is omitted.
See also
- Multi-signer wallets — the conceptual mental model
- Custodial vs non-custodial — the side-by-side comparison
- Withdrawals — full state diagram for the payout lifecycle
- Webhooks reference — every topic with full payload schema
- Error codes — failure-code resolution playbooks
- Sandbox overview