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

# Multi-signer wallets recipe

> Drive a multi-signer non-custodial payout end-to-end in sandbox: claim non-custodial control, distribute per-signer enrollment links, watch the per-stamp progress events, and finalize.

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

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

  ```javascript theme={null}
  // 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, outcome: 'approved' });
  }
  // → transaction.completed fires (compliance auto-stamps inside the sandbox)
  ```

  These endpoints exist in sandbox only — they have no live counterpart.
</Note>

## Before you claim: activate the CRYPTO\_WALLET feature

`claim-non-custodial` requires an approved `CRYPTO_WALLET` feature on the customer. The pattern is:

```bash theme={null}
# 1. Activate crypto for the customer
curl -X POST "https://api.sandbox.conduit.financial/v2/customers/${CUSTOMER_ID}/features" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "type": "crypto_wallet" }'
```

In sandbox the default is auto-approve: the response carries `status: "approved"` and you can claim immediately. To rehearse the live review path, drive the application terminal yourself:

```bash theme={null}
# Force a manual-review outcome on a pending CRYPTO_WALLET application
curl -X POST "https://api.sandbox.conduit.financial/v2/sandbox/applications/${APPLICATION_ID}/simulate/decision" \
  -H "x-api-key: ${SANDBOX_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "approved" }'
```

Calling `claim-non-custodial` before the feature is approved returns `422 CRYPTO_FEATURE_NOT_APPROVED`. Calling it from a country on Conduit's crypto-restricted list returns `422 CRYPTO_NOT_AVAILABLE_IN_JURISDICTION` at the feature-application step (the claim never runs).

## The 6-step recipe

<Steps>
  <Step title="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 every supported chain in one claim; pass it to narrow the set (see [Crypto Wallets](/concepts/crypto-wallets#creating-wallets)). The customer must have an approved `CRYPTO_WALLET` feature (see above).

    ```bash theme={null}
    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`:

    ```json theme={null}
    {
      "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](/errors) for the full list of claim-time rejection reasons.
  </Step>

  <Step title="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`.

    ```json theme={null}
    {
      "topic": "wallet_signer.invited",
      "data": {
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
        "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
        "email": "ada@example.com",
        "name": "Ada",
        "role": "admin",
        "credentialType": "passkey",
        "verificationUrl": "https://app.conduit.financial/verify/vtok_3yLkG0nRc8wO5iM2qS4x9u",
        "expiresAt": "2026-01-22T09:30:00.000Z"
      }
    }
    ```
  </Step>

  <Step title="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.
  </Step>

  <Step title="Receive one `wallet_signer.enrolled` webhook per signer">
    When a signer opens their URL and completes enrollment, you receive:

    ```json theme={null}
    {
      "topic": "wallet_signer.enrolled",
      "data": {
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
        "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
        "email": "ada@example.com",
        "role": "admin",
        "passkeyCount": 1
      }
    }
    ```

    Track these against the `walletSignerId`s you saw in step 2 to know when the last signer has enrolled.
  </Step>

  <Step title="Wallets become usable (sandbox auto-activation)">
    Once every roster member has enrolled, sandbox auto-activates the customer's wallets and fires `claim.completed`, carrying the `claimId` and the activated `walletIds`:

    ```json theme={null}
    {
      "topic": "claim.completed",
      "data": {
        "claimId": "wcc_2xKjF9mQb7vN4hL1pR3w8t",
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
        "walletIds": ["wlt_2xKjF9mQb7vN4hL1pR3w8t"]
      }
    }
    ```

    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.

    <Warning>
      Auto-activation is sandbox-only. In live, activation runs an additional ceremony — see [Sandbox vs live caveats](#sandbox-vs-live-caveats).
    </Warning>
  </Step>

  <Step title="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 at the cosign gate. 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](/guides/machine-signer-stamping).

    ```json theme={null}
    {
      "topic": "transaction.awaiting_signature",
      "data": {
        "signingMode": "passkey_required",
        "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
        "verificationUrl": "https://app.conduit.financial/verify/vtok_2xKjF9mQb7vN4hL1pR3w8t",
        "requiredApprovals": 2,
        "expiresAt": "2026-01-15T09:45:00.000Z",
        "attempt": 1
      }
    }
    ```

    **3. `transaction.signature_collected`** — fires once per signer stamp. Drive a progress UI off `collected` / `required`.

    ```json theme={null}
    {
      "topic": "transaction.signature_collected",
      "data": {
        "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
        "walletSignerId": "wsg_2xKjF9mQb7vN4hL1pR3w8t",
        "collected": 1,
        "required": 2
      }
    }
    ```

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

    ```json theme={null}
    {
      "topic": "transaction.quorum_met",
      "data": {
        "transactionId": "txn_2xKjF9mQb7vN4hL1pR3w8t",
        "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t"
      }
    }
    ```

    **5. `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](#failure-cases).
  </Step>
</Steps>

<Note>
  **How compliance works in sandbox.** Compliance + Travel Rule screening runs **before** the signers are asked to sign; in sandbox it auto-passes, so the payout flips to `processing` and reaches the co-signing gate immediately. Once the signers reach the configured threshold, the sandbox casts Conduit's final compliance approval, so `transaction.quorum_met` is followed within milliseconds by `transaction.completed`. In live, the pre-signing screening runs the real sanctions + travel-rule checks; after quorum Conduit casts its final compliance approval to complete the transfer, applying the screening result already on file rather than re-screening.
</Note>

## Failure cases

A payout that does not complete fires `transaction.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. |
| `compliance_review_rejected`          | Compliance screening rejected the payout before signing, so it never reached the signers. 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).              |

See the [error reference](/errors) 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.

```bash theme={null}
# 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](/concepts/signing-thresholds).

### Promote a signer into the root quorum (seat swap)

`POST /v2/customers/{customerId}/wallet-signers/{signerId}/promote` seats a signer (or a non-root admin) in the root quorum; `.../demote` unseats a root admin. Each call runs a root-quorum ceremony in the background.

The root quorum holds a fixed two customer seats. When both are filled — as they are on the two-admin roster from the recipe above — promoting a new admin into it is a **seat swap**: the promote call names the current root admin to unseat via `demoteSignerId`, and one ceremony does both.

```bash theme={null}
# Promote signer Ken into the root quorum, unseating Grace in the same ceremony.
# demoteSignerId is required here because both root seats are filled.
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerKenId}/promote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{ "demoteSignerId": "{signerGraceId}" }'
# 202 Accepted. `wallet_signer.promoted` (Ken) and `wallet_signer.demoted` (Grace)
# fire when the ceremony completes (typically a few seconds). The root quorum still
# holds two customer seats — now Ada and Ken; Grace reverts to a signer.

# Demote an admin back to signer (only valid while the roster keeps >= 2 admins).
curl -X POST 'https://api.sandbox.conduit.financial/v2/customers/{customerId}/wallet-signers/{signerAdaId}/demote' \
  -H "x-api-key: $SANDBOX_API_KEY" -H "idempotency-key: $(uuidgen)"
# 202 Accepted. `wallet_signer.demoted` fires on completion.
```

Constraints:

* Promoting without `demoteSignerId` while both root seats are filled returns `409 ROOT_AT_CAPACITY_SWAP_REQUIRED`. Retry with a `demoteSignerId`.
* `demoteSignerId` must name a current root-quorum admin, else `409 DEMOTE_TARGET_NOT_IN_ROOT`.
* A swap that would leave fewer than 2 admins on the roster returns `409 SWAP_WOULD_BREAK_F12`.
* Running a second promote/demote before the first ceremony completes returns `409 CEREMONY_IN_FLIGHT`. Retry after a short backoff.
* Demoting an admin that would drop the admin count below the floor returns `409 WOULD_BREAK_MIN_ADMINS` (at the default two root seats, demoting either root admin hits this).

#### 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 (webhook payloads have no `urlAudience` field — that field belongs to the `POST /wallet-signers` response). In production an admin opens that link and approves with their passkey. In sandbox, stamp it headlessly using the verification token from that URL:

```bash theme={null}
curl -X POST 'https://api.sandbox.conduit.financial/v2/sandbox/verifications/{token}/simulate/ceremony-stamp' \
  -H "x-api-key: $SANDBOX_API_KEY" -H 'Content-Type: application/json' \
  -d '{ "outcome": "approved" }'
# 200 — the parked ceremony resumes. { "outcome": "declined" } rejects it instead.
# Replaying against an already-decided token returns the verification at its current state.
```

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

```bash theme={null}
# Assume the customer has root admins 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 — seat swap: promote a new admin D into the root quorum and unseat A in
# the same ceremony, so the roster keeps two root admins throughout. Then remove A
# (now a non-root signer). 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)" \
  -H 'Content-Type: application/json' \
  -d '{ "demoteSignerId": "{signerAId}" }'
# Wait for `wallet_signer.promoted` (D) and `wallet_signer.demoted` (A) before the next call.

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, seated into the root quorum)
2. `wallet_signer.demoted` (signer A, unseated by the same swap)
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](/concepts/multi-signer-wallets#concepts) for the underlying model.

## Sandbox vs live caveats

<Tip>
  Every webhook topic and payload on this page is identical between sandbox and live. The differences below are operational, not contractual.
</Tip>

<Note>
  Multi-signer wallets are currently available in sandbox only. The live integration is still in progress; live builds reject new multi-signer methods with a sandbox-only stub for now.
</Note>

* **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.
* 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. `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 in live; in sandbox it is omitted.

## See also

* [Multi-signer wallets](/concepts/multi-signer-wallets) — the conceptual mental model
* [Custodial vs non-custodial](/sandbox/custody) — the side-by-side comparison
* [Withdrawals](/sandbox/withdrawals) — full state diagram for the payout lifecycle
* [Webhooks reference](/webhooks) — every topic with full payload schema
* [Error codes](/errors) — failure-code resolution playbooks
* [Sandbox overview](/sandbox/overview)
