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, 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.activated 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.
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:
  • 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.
// 1. Claim non-custodial control
const { claimId } = await api.post(`/v2/customers/${customerId}/wallets/claim-non-custodial`, {...});

// 2. Mark each signer enrolled (skips the portal entirely)
for (const signer of webhookSignersInvited) {
  await api.post(`/v2/sandbox/wallet-signers/${signer.walletSignerId}/mark-enrolled`);
}
// → crypto_wallet.completed fires once all enrolled

// 3. Submit a payout, then stamp it from the sandbox endpoint
const { id: payoutId } = await api.post('/v2/payouts', {...});
for (const signer of admins.slice(0, signingThreshold)) {
  await api.post(`/v2/sandbox/payouts/${payoutId}/simulate-stamp`, { walletSignerId: signer.id });
}
// → transaction.completed fires (compliance auto-stamps inside the sandbox)
These endpoints exist in sandbox only — they have no live counterpart.

The 6-step recipe

1

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.
curl -X POST "https://api.sandbox.conduit.financial/v2/customers/cus_2xKjF9mQb7vN4hL1pR3w8t/wallets/claim-non-custodial" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "idempotency-key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "roster": [
      { "email": "ada@example.com",   "name": "Ada",   "role": "admin",  "credentialType": "passkey" },
      { "email": "grace@example.com", "name": "Grace", "role": "admin",  "credentialType": "passkey" },
      { "email": "ken@example.com",   "name": "Ken",   "role": "signer", "credentialType": "passkey" }
    ],
    "signingThreshold": 2,
    "chains": ["ethereum", "polygon"]
  }'
Returns 202 Accepted:
{
  "claimId": "wcc_2xKjF9mQb7vN4hL1pR3w8t",
  "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
  "status": "provisioning",
  "rosterSize": 3,
  "signingThreshold": 2,
  "estimatedActivationCompletionMinutes": null
}
Roster validation runs before any side effects: roster size ≥ 2, admin count ≥ 2, threshold ≥ 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.
{
  "topic": "wallet_signer.invited",
  "data": {
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
    "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
    "email": "ada@example.com",
    "name": "Ada",
    "role": "admin",
    "credentialType": "passkey",
    "verificationUrl": "https://verify.conduit.financial/verify/vtok_3yLkG0nRc8wO5iM2qS4x9u",
    "expiresAt": "2026-01-22T09:30:00.000Z"
  }
}
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:
{
  "topic": "wallet_signer.enrolled",
  "data": {
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
    "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
    "email": "ada@example.com",
    "role": "admin",
    "passkeyCount": 1
  }
}
Track these against the walletSignerIds you saw in step 2 to know when the last signer has enrolled.
5

Wallets become usable (sandbox auto-activation)

Once every roster member has enrolled, sandbox auto-activates the customer’s wallets. You receive one crypto_wallet.completed per chain you requested:
{
  "topic": "crypto_wallet.completed",
  "data": {
    "walletId": "wlt_2xKjF9mQb7vN4hL1pR3w8t",
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
    "chain": "ethereum",
    "custodyModel": "non_custodial"
  }
}
Deposit addresses are now available via GET /v2/customers/:customerId/wallets. The customer can receive funds and submit payouts.
Auto-activation is sandbox-only. In live, activation runs an additional ceremony — see Sandbox vs live caveats.
6

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.
{
  "topic": "transaction.awaiting_user_signature",
  "data": {
    "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
    "verificationUrl": "https://verify.conduit.financial/verify/vtok_2xKjF9mQb7vN4hL1pR3w8t",
    "requiredApprovals": 2,
    "expiresAt": "2026-01-15T09:45:00.000Z",
    "attempt": 1
  }
}
2. transaction.signature_collected — fires once per signer stamp. Drive a progress UI off collected / required.
{
  "topic": "transaction.signature_collected",
  "data": {
    "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
    "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
    "collected": 1,
    "required": 2
  }
}
3. transaction.quorum_met — fires once when collected >= required. The compliance stamp runs next.
{
  "topic": "transaction.quorum_met",
  "data": {
    "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
    "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t"
  }
}
4. 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 fires transaction.failed with one of the codes below.
failureCodeMeaningRetryable?
USER_SIGNATURE_DECLINEDA signer rejected the payout from the approval page.Yes — submit a new payout.
USER_SIGNATURE_TIMEOUTThe signing window expired before quorum was reached.Yes — submit a new payout.
USER_SIGNATURE_REJECTED_BY_PROVIDERA signer’s passkey approval could not be accepted.Yes — submit a new payout. Contact support on repeat.
ROSTER_CHANGEDA 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_REJECTEDThe compliance stamp rejected the payout after quorum. No funds moved.No — contact support.
PROVIDER_REJECTEDThe 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).
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 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.
# Step 1 — add the fourth signer
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers' \
  -H "x-api-key: $SANDBOX_API_KEY" \
  -H "idempotency-key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "dave@example.com",
    "role": "signer",
    "credentialType": "passkey"
  }'
# Response: signer row in PENDING_ACTIVATION. `wallet_signer.invited` + `wallet_signer.added` fire.

# Step 2 — enroll the new signer (sandbox bypasses the real passkey UX)
curl -X POST 'https://api.sandbox.conduit.financial/v2/sandbox/wallet-signers/{newSignerId}/mark-enrolled' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# `wallet_signer.enrolled` fires.

# Step 3 — subsequent payouts collect stamps from any 2 of the 4 active signers.
# (Threshold stays at 2; only the eligible-stamper pool grew.)
Expected webhooks:
  1. wallet_signer.invited (new signer; payload carries verificationUrl)
  2. wallet_signer.added (co-emitted)
  3. wallet_signer.enrolled (after mark-enrolled)
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.

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.
# Promote signer C to admin
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerCId}/promote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# 202 Accepted. `wallet_signer.promoted` fires when the ceremony completes (typically a few seconds).

# Demote signer A back to signer
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerAId}/demote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# 202 Accepted. `wallet_signer.demoted` fires on completion.
Constraints:
  • 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".
# Assume the customer has signers A and B with a 2-of-2 quorum,
# and an in-flight payout where A has already stamped (1 of 2 collected).

# Step 1 — promote a new admin so the min-admin floor stays satisfied,
# then demote + remove signer A. Each ceremony is sequential per customer.
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerDId}/promote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# Wait for `wallet_signer.promoted` before issuing the next call.

curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerAId}/demote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# Wait for `wallet_signer.demoted`.

curl -X DELETE 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerAId}' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# `wallet_signer.removed` fires. Signer A's stamp is scrubbed from the in-flight payout.
Expected webhook order:
  1. wallet_signer.promoted (signer D, replacement admin)
  2. wallet_signer.demoted (signer A)
  3. wallet_signer.removed (signer A)
  4. transaction.signature_collected (votesCollected: 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

Every webhook topic and payload on this page is identical between sandbox and live. The differences below are operational, not contractual.
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.completed fires 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 resolves transaction.completed within ~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 txHash on a sandbox transaction.completed is 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-enrolled endpoint 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.completed does not fire the instant the last signer enrolls.
  • The queuePosition field on signing-queue webhooks is populated by the real per-(wallet, chain) counter (C2-1543); in sandbox it is omitted.

See also