Skip to main content

Overview

This page walks the multi-signer non-custodial flow as it works in sandbox: a customer claims non-custodial control, the roster reaches its signing quorum, deposit addresses become available, and a payout collects a stamp from each required signer before broadcasting. The same six webhook topics fire in live, over the same activation path — see Sandbox vs live caveats for what actually differs.

Pick your roster to match the signing mode

The roster shape you claim with depends on the customer’s signing mode, which Conduit sets per organization, with an optional per-customer override. Send the wrong shape and the claim is rejected. Read the mode before you build the roster: for a customer with no wallet claim yet, GET /v2/customers/:id returns the current signingMode — the value this claim would freeze. Once a customer has claimed, its wallets keep the mode they froze at claim time even if signingMode changes afterward.
A new sandbox organization defaults to programmatic_unattended. The mode limits api_key members only; passkeys count toward signingThreshold in every mode, so an all-passkey roster is valid too. programmatic_unattended (the sandbox default) permits api_key admins, so the shortest path is a machine-signer roster — at least two api_key admins, each carrying a compressed publicKey (P-256 by default, or a secp256k1 key with signatureScheme: "secp256k1" / "secp256k1_eip191"), with signingThreshold reached by those keys. (The stricter programmatic mode rejects api_key admins with 422 SIGNING_MODE_ROSTER_INVALID: there the machine members take role: "signer" and admins are passkeys.)
Machine members need no enrollment — there is no wallet_signer.invited / wallet_signer.enrolled cycle (steps 2–5 below). The claim returns status: "provisioning", and activation then completes on its own and claim.completed fires; await that webhook before using the wallets. You then sign each payout with a machine stamp at POST /v2/signing-requests/:id/approve; the full generate-key-and-stamp loop is in Machine-signer stamping. The customer’s mode surfaces publicly as signingMode on the transaction.awaiting_signature webhook.The 6-step passkey recipe below works in every signing mode. It is the only recipe for a customer set to passkey_required (Conduit sets this; a sandbox organization only ends up there through an explicit organization-level or per-customer override, since the default is programmatic_unattended), because that mode refuses any api_key member.

Individual customer: one admin

An individual customer’s roster has exactly one admin. The simplest roster is one member, the customer, as a passkey admin under the individual’s email on file, at signingThreshold: 1. Under a programmatic signing mode you can add api_key members with role: "signer" beside that admin. The machine-signer shortcut above takes one change for an individual: under programmatic unattended, name one api_key admin, and make every other member an api_key signer. A second admin, a second person, a passkey member who is not the admin or a different email returns 422 INDIVIDUAL_ROSTER_INVALID.
The person opens the verificationUrl from wallet_signer.invited and registers two passkeys, for example on a phone and a laptop. After the first passkey the member reads activating; after the second it turns active, the wallets activate, and claim.completed fires. Conduit cannot sign or change the wallet without the customer.

Prerequisites

  • The customer must be approved (application.approved and customer.created fired).
  • 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.

Before you claim: activate the CRYPTO_WALLET feature

claim-non-custodial requires an approved CRYPTO_WALLET feature on the customer — see Sandbox quickstart → Step 9.1 for the request and the sandbox auto-approve default. Calling claim-non-custodial before the feature is approved returns 422 CRYPTO_FEATURE_NOT_APPROVED. A country on Conduit’s crypto-restricted list hits 422 CRYPTO_NOT_AVAILABLE_IN_JURISDICTION at the feature-application step (POST /features), before you ever reach the claim call.

The 6-step recipe

This recipe is the passkey path — each signer enrols a passkey and stamps on the verify page. It works in every signing mode. For a programmatic_unattended customer (the sandbox default), the machine-signer roster above is the shorter path: sign via Machine-signer stamping; that roster needs no enrollment (its wallets activate on their own after the claim — await claim.completed), so steps 2–5 do not apply.
1

Claim non-custodial control

POST /v2/customers/:customerId/wallets/claim-non-custodial with a roster, a signing threshold, and an optional chains array. Omit chains to provision a wallet on each default chain that settles on sandbox in one claim (tron does not settle here, so it is left out); pass it to choose the chains, including ones outside the default set such as solana (see Crypto Wallets). The customer must have an approved CRYPTO_WALLET feature (see above).
Returns 202 Accepted:
estimatedActivationCompletionMinutes is an advisory estimate fixed at claim time (30 minutes by default), not an SLA. A machine-signer roster typically activates well inside it, right after the claim; a passkey roster can take longer, since it waits on people to enroll.Roster validation runs before any side effects: a business roster has exactly 2 admins, an individual’s roster has exactly one admin and at most one person, and the threshold is ≥ 1 and ≤ roster size. See error codes for the full list of claim-time rejection reasons.
2

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, 7 days after the invitation.
3

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

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

Wallets become usable

Once every roster member has enrolled, the wallets activate automatically and claim.completed fires, carrying the claimId and the activated walletIds:
A customer-level crypto_wallet.completed (carrying customerId only, no wallet IDs) also fires. Deposit addresses are now available via GET /v2/customers/:customerId/wallets. The customer can receive funds and submit payouts.
6

Submit a payout — stamps collect over webhook

POST /v2/payouts returns 202 { id: "txn_...", status: "pending" }. The payout is screened for compliance + Travel Rule before any signer is asked to sign, so a payout that fails screening is rejected before the roster ever sees it. Once screening clears the status flips to processing and the payout drives these event topics:1. transaction.processing — fires once when compliance + Travel Rule clear and the payout enters the signing queue. Progress signal only; it carries no verificationUrl, so do not route signers off this event.2. transaction.awaiting_signature — fires once when the payout parks for signing. In the passkey signing mode (shown here) the verificationUrl is a single shared approval page; the roster members signed-in there each stamp the payout with their passkey. A wallet in a programmatic signing mode carries a signingRequestId — plus an optional verificationUrl when its roster has an active passkey signer who may also approve on the verify page — see Machine-signer stamping.
3. transaction.signature_collected — fires once per signer stamp. Drive a progress UI off collected / required.
4. transaction.quorum_met — fires once when collected >= required. Compliance already passed before signing; once quorum is met, Conduit casts its final compliance approval to complete the transfer — applying the screening result already on file, not re-screening after signing.
5. transaction.completed (or transaction.failed) — fires once when the payout reaches a terminal state. In sandbox it fires after the payout reaches real testnet finality, which takes minutes. transaction.failed carries a failureCode your integration branches on — see Failure cases.
How compliance works in sandbox. Compliance + Travel Rule screening runs before the signers are asked to sign, using the same scenario suffixes as the rest of sandbox: a request with no matching suffix clears immediately, so the payout flips to processing and reaches the signing gate right away. In live, the same pre-signing screening runs the real sanctions and Travel Rule checks. Either way, once the signers reach the configured threshold, Conduit completes the transfer by applying the screening result already on file — see transaction.quorum_met above.

Failure cases

A payout that does not complete fires transaction.failed with one of the codes below. See the error reference for the full catalog and resolution playbooks.

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 ceremonies are 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.
Expected webhooks:
  1. wallet_signer.invited (new signer; payload carries verificationUrl)
  2. wallet_signer.added (co-emitted)
  3. wallet_signer.enrolled (after the signer registers a passkey)
To also raise the threshold (e.g. to require 3 of 4), call the customer-quorum endpoint after the new signer is enrolled. See Signing thresholds.

Replace an admin with a signer

POST /v2/customers/:customerId/wallet-signers/:signerId/promote makes a signer an admin in place of the admin named in demoteSignerId. A business has exactly two admins, so this replacement is the only way to change who is an admin; one ceremony does both changes in the background.
Constraints:
  • Promoting without demoteSignerId returns 409 ROOT_AT_CAPACITY_SWAP_REQUIRED. Retry with a demoteSignerId.
  • demoteSignerId must name one of the customer’s admins, else 409 DEMOTE_TARGET_NOT_IN_ROOT.
  • Running a second promote before the first ceremony completes returns 409 CEREMONY_IN_FLIGHT. Retry after a short backoff.
  • .../demote returns 409 WOULD_BREAK_MIN_ADMINS for every admin: replace the admin with a promote instead.

Parked ceremony approvals

Some roster changes park awaiting an existing admin’s approval before the ceremony runs: the wallet_ceremony.awaiting_admin_approval webhook fires carrying the admin link in its adminVerificationUrl field. An admin opens that link and approves with their passkey, in sandbox and in production. The parked ceremony then resumes; a decline rejects it.

Ghost-vote scrubbing: signer removed mid-payout

DELETE /v2/customers/:customerId/wallet-signers/:signerId removes a signer. It returns 204 No Content; the removal outcome is disclosed additively in the response headers — X-Conduit-Ceremony-Status (pending_removal while the roster-remove ceremony awaits admin approval, or removed once fully removed) and X-Conduit-Ceremony-Id (the ceremony id; omitted once terminally removed). Retrying the same DELETE is idempotent. 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".
Expected webhook order:
  1. wallet_signer.promoted (signer D, now an admin)
  2. wallet_signer.demoted (signer A, replaced in the same ceremony)
  3. wallet_signer.removed (signer A)
  4. transaction.signature_collected (collected: 0) — re-fires after the scrub
  5. transaction.failed with failureCode: "roster_changed" — payout cannot reach quorum on the new roster
Client recovery: re-submit the payout. The new attempt collects stamps from the current roster. See ghost-vote scrubbing for the underlying model.

Sandbox vs live caveats

Signing and broadcast are real in sandbox. A passkey_required wallet signs with a real passkey, a programmatic wallet signs with a machine stamp at POST /v2/signing-requests/:id/approve, and the payout broadcasts a real txHash you can open on the explorer. See Use a real testnet and Machine-signer stamping.
Every webhook topic and payload on this page is identical between sandbox and live. The differences below are operational, not contractual.
Multi-signer wallets run in production, and the recipe on this page is the recipe you integrate against there. A passkey signer enrolls and approves with a real device-bound passkey in sandbox and in live, so that path needs a person at a device. A machine-signer roster runs without one.
  • Roster and enrollment ceremonies can wait for an admin approval. In production, adding a signer, removing a signer and attaching a passkey each need one business-admin stamp, so the call answers 202 Accepted with a ceremony id and an admin verification URL. Promotion, demotion and signing-quorum changes wait for an approval in sandbox too. When a ceremony parks, an admin opens adminVerificationUrl and approves with their passkey.
  • Chain finality is real. After quorum_met, the payout broadcasts on a real testnet and transaction.completed fires at finality, which takes minutes. Its txHash resolves on the network’s explorer.

What changes in live

The contract you integrate against is the same. The pieces underneath swap out:
  • The real compliance pipeline (sanctions screening + travel rule) replaces the scenario-suffix simulation. A payout with no suffix to trigger in sandbox can still be rejected here by real screening.
  • The per-(wallet, chain) signing queue runs the same in sandbox as in live: a queued payout carries a positive queuePosition on GET /v2/payouts/:id and on the transaction.queue_position_changed webhook in both.

Sandbox-only levers

POST /v2/sandbox/customers/:customerId/simulate-reset-claim tears down the customer’s wallet setup so a claim can be re-run. A production wallet claim is not reversible.

See also