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

> M-of-N customer-controlled signing on non-custodial wallets: roster, threshold, admins, ceremonies, and webhooks in one place.

Multi-signer is the canonical non-custodial wallet model. The customer holds their own signing material across a team: they enroll a roster of named signers (each with their own passkey), pick a signing threshold, and every payout pauses on a quorum gate until enough signers stamp it. New customers reach a usable wallet exclusively through this path.

This page is the single mental model. For the deposit/payout walkthroughs that exercise these endpoints, see [Withdrawals](/sandbox/withdrawals) and the [Multi-signer wallets recipe](/sandbox/multi-signer-wallets#the-6-step-recipe) (with roster ceremonies).

<Note>
  A signer doesn't have to be a person with a passkey. If Conduit has enabled a programmatic signing mode for your customer, your **backend** can hold a signing key and approve payouts server-to-server, with no browser and no human. See [Programmatic payout signing](/guides/machine-signer-stamping).
</Note>

## When to use which custody model

By default, customers provision non-custodial wallets via the `claim-non-custodial` endpoint, which always creates a roster of **at least two admins**. You choose the M-of-N signing threshold — as low as 1 (a single stamp clears each payout) or higher for multi-party approval. The custodial column below applies only to orgs Conduit has configured for custodial wallets; for those, `POST /v2/customers/:id/wallets` provisions the account directly with no claim.

|                            | **Custodial**                                                                 | **Non-custodial (threshold 1)**                                                               | **Non-custodial (threshold M-of-N)**                 |
| -------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Who holds signing material | Conduit                                                                       | Customer (a passkey per roster member, ≥2 admins)                                             | Customer team (a passkey per roster member)          |
| Provisioned via            | `POST /v2/customers/:id/wallets` (first call; custodial-configured orgs only) | `POST /v2/customers/:id/wallets/claim-non-custodial` (≥2-admin roster, `signingThreshold: 1`) | `POST /v2/customers/:id/wallets/claim-non-custodial` |
| Payout signing             | Auto                                                                          | One roster signer stamps via the verify page                                                  | M-of-N roster, each stamps independently             |
| Use when                   | Conduit-configured custodial orgs                                             | You want a single approval per payout                                                         | You want multi-party approval                        |

Every non-custodial wallet is a roster of at least two admins; the signing threshold M is what you choose. Threshold 1 clears on a single stamp, higher thresholds need M stamps. The custodial model has no customer signer at all. The passkey columns above describe the **passkey-required** signing mode; in a programmatic signing mode, signer seats (and, in the unattended variant, admin seats) can be held by machine API keys instead — see [Programmatic payout signing](/guides/machine-signer-stamping).

## Concepts

**Roster.** The ordered list of named signers attached to a customer's non-custodial setup. Each signer has an email, a role (`admin` or `signer`), and a credential: passkey members enroll passkeys — plain signers one, admins two on two separate devices (see [Backup passkeys](#backup-passkeys)) — while machine (`api_key`) members carry a registered P-256 public key and enroll nothing. The email identifies and dedups the signer — Conduit never contacts the signer directly; distributing each signer's enrollment link is your responsibility (see [Lifecycle endpoints](#lifecycle-endpoints) below). Roster size and threshold are decoupled: a 5-member roster can require 2 stamps; a 2-member roster can require 2.

The customer's signing mode constrains the roster, both at claim time and on later adds: `api_key` members are rejected in the passkey-required mode, and an `api_key` member with `role: "admin"` is rejected outside the unattended mode — both with `422 SIGNING_MODE_ROSTER_INVALID`. In either programmatic mode the machine signers alone must be able to reach `signingThreshold`; a roster where they can't (including an all-passkey roster) is rejected with `422 PROGRAMMATIC_QUORUM_UNREACHABLE`.

**Threshold.** The integer `M` in M-of-N. Every payout collects stamps until `M` are recorded, then auto-broadcasts. The threshold is set at `claim-non-custodial` time and can be adjusted later via the signing-quorum endpoints. See [Signing thresholds](/concepts/signing-thresholds) for the constraints.

**Admin vs signer.** Both roles stamp payouts; only admins can change the roster (add, remove, promote, demote other members). The minimum-admin floor (2 admins) is enforced on every roster mutation so you can never lock yourself out.

**Root quorum.** The governance layer that protects the customer's signing setup itself — the roster of allowed signers, the threshold, the underlying keys. It is a **fixed** set: two Conduit seats plus exactly two customer admin seats, and it always requires all but one of its members to approve a governance change. Because the two customer seats can never reach that bar on their own, no roster or threshold change goes through without a Conduit approval — that is the governance veto. The customer chooses which two of their admins hold the root seats when they claim the wallet.

**Non-root admins.** A customer can have more than two admins. Every admin can co-approve governance changes, but only the two who hold root seats are counted toward the root quorum. Admins beyond those two are **non-root admins**: full admins on the roster, not members of the root quorum. Promoting a signer or non-root admin into the root quorum is a [seat swap](#promoting-into-the-root-quorum-is-a-seat-swap), never an addition — the root quorum stays at two customer seats.

**Ceremony.** A short-lived background flow that updates the root quorum (on promote/demote) or rolls the roster (on add/remove). Ceremonies are sequential per customer; a second one queues if one is already in flight. You see this surface as: a promote/demote operation returns `202 Accepted` and the change shows up after the ceremony completes.

**Roster changes and the payout threshold are separate.** Adding or removing a signer takes a single admin approval, no matter what signing threshold you set. Your `signingThreshold` governs only how many stamps a *payout* needs; it does not change how many admins must approve a *roster* change. So a one-admin approval on an add or remove is expected even on a wallet that requires several stamps per payout — the two settings are independent.

**Ghost-vote scrubbing.** If a signer is removed while a payout is sitting at the quorum gate, that signer's already-cast stamps are scrubbed from every in-flight payout for the customer. Affected payouts re-fire `transaction.signature_collected` with the new count; payouts that can no longer reach quorum on the new roster terminate with `failureCode: "roster_changed"` so clients can re-submit against the current roster.

## Backup passkeys

A plain signer enrolls with one passkey; an admin enrolls two, on two separate devices, so losing one device never unseats an admin. Any active signer can add another passkey after enrollment from the Conduit-hosted **signer profile at `https://app.conduit.financial/signer-profile`** — one stable page for every signer, with no per-signer token: the signer opens it, signs in with an existing passkey, and registers a new device. Unlike the enrollment link (one tokenized URL per invited signer), this is a single fixed URL you can publish to your whole roster. The add takes a single admin co-approval before the new passkey is usable: the request parks pending approval and returns an `adminVerificationUrl` (in a `409` parked-pending-approval response body) for one of your admins to approve. Because the signer is already active, approving fires `wallet_signer.enrollment_approved` — not `wallet_signer.enrolled`, which only fires on a signer's first activation — and no `wallet_ceremony.*` webhook fires for this self-service path. Distributing both the signer-profile URL and the admin-approval link is your responsibility — Conduit never contacts signers directly.

Because a root-quorum admin must hold two passkeys, a plain signer has to add a second passkey **before** it can be [promoted](#lifecycle-endpoints) into the root quorum.

## Recovery

Recovery replaces a signer's or admin's passkey when a device is lost — without re-onboarding the customer or re-claiming the wallet. It is **admin-driven**: a customer admin initiates it from the Conduit dashboard (not through the public API), and a locked-out signer can separately signal intent from their signer profile. Conduit co-approves every recovery — the same governance veto that protects roster changes — and high-risk patterns (a second recovery within a short window, or overlapping open cases) are auto-refused. A recovery may include a cooling-off delay before it completes.

From an integration's point of view recovery surfaces as a ceremony on your webhook stream, with no roster-membership change (so `wallet_signer.added` / `wallet_signer.removed` do not fire):

* `wallet_ceremony.awaiting_admin_approval` with `type: "recovery"` — a recovery is open and waiting on an admin.
* `wallet_ceremony.completed` — the replacement credential is in place and the signer is usable again with the same seat.
* `wallet_ceremony.failed` — the recovery was declined, expired, or auto-refused by a risk check.

When an admin can no longer be recovered directly, a remaining admin seats a replacement into the root quorum instead; if that changes the signing threshold, `wallet.threshold_changed` also fires.

## Promoting into the root quorum is a seat swap

The root quorum holds a fixed number of customer seats (two). Once both are filled, you cannot grow it — promoting a signer into the root quorum means **unseating an existing root admin in the same ceremony**. This is the only way to change who holds a root seat; there is no "add a third root seat" path.

Worked example. A customer claims with two admins, Ada and Grace, who take the two root seats, plus a signer, Ken. Later they want Ken in the root quorum. Because both root seats are filled, a bare promote is rejected with `409 ROOT_AT_CAPACITY_SWAP_REQUIRED` — there is no free seat to grant. The only way in is a seat swap:

* **Put Ken in the root quorum (seat swap).** Promote Ken *and* name the current root admin to unseat in the same call: `demoteSignerId` = Grace's signer id. One ceremony seats Ken and unseats Grace. The root quorum still holds two customer seats (Ada and Ken); Grace loses her admin role and reverts to a signer. The threshold is unchanged.

`demoteSignerId` is optional on the promote endpoint but **required whenever the root quorum is already at its seat capacity**. It must name a current root-quorum admin, and the swap must leave the roster with at least two admins. Because the root stays the same size, the governance veto never weakens: two customer seats, and a Conduit approval is always needed on top.

## Lifecycle endpoints

| Endpoint                                                          | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v2/customers/:customerId/wallets/claim-non-custodial`      | Provision the customer's multi-signer setup. Required body: `roster`, `signingThreshold`. Returns 202 with a `claimId`; per-signer enrollment URLs fan out as `wallet_signer.invited` webhooks **to your endpoint** — Conduit never contacts signers directly, so you send each signer their own URL out of band. The two admins named in the roster take the two root-quorum seats.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `POST /v2/customers/:customerId/wallet-signers`                   | Add a signer to an existing roster. An add to a live roster typically parks for one admin approval. Returns the new signer row in `pending_activation` with a `verificationUrl` and a `urlAudience` telling you who that link is for: `signer` = the new signer's own enrollment link (also delivered via `wallet_signer.invited`) — forward it to the signer; `admin` = a link for one of your existing admins to approve the add (also delivered via `wallet_ceremony.awaiting_admin_approval`) — forward it to an admin. **For a passkey signer the sequence is two steps:** the response gives the `signer` link, the signer enrolls their passkey, and only then does the add park for the admin co-stamp — whose link arrives **only** on the `wallet_signer.awaiting_admin_approval` webhook (keyed on `signerId`), never in this response. An `api_key` signer parks at the POST instead, so its admin link comes back inline as `urlAudience: "admin"`. If you lose the admin webhook, recover the link with `POST /v2/customers/:customerId/wallet-signers/:signerId/reissue-admin-approval`. `verificationUrl` is `null` when no link applies. Conduit never contacts anyone directly — always route by `urlAudience`. |
| `DELETE /v2/customers/:customerId/wallet-signers/:signerId`       | Remove a signer. Admins must be demoted first (returns `409 SIGNER_IS_ROOT_MEMBER` otherwise).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `POST /v2/customers/:customerId/wallet-signers/:signerId/promote` | Promote a signer (or a non-root admin) into the root quorum. Optional body `demoteSignerId` names the current root admin to unseat in the same ceremony — **required when the root quorum is at its seat capacity** (a seat swap). Triggers a root-quorum ceremony.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `POST /v2/customers/:customerId/wallet-signers/:signerId/demote`  | Demote a root admin back to signer. Triggers a root-quorum ceremony.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

For the quorum-threshold endpoints (per-customer + per-wallet overrides) see [Signing thresholds](/concepts/signing-thresholds).

## Sandbox simulators

| Endpoint                                                      | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v2/sandbox/wallet-signers/:signerId/mark-enrolled`     | Marks a `pending_activation` signer as enrolled. Fires `wallet_signer.enrolled`; once every roster member is enrolled, the wallet flips to `active` and `crypto_wallet.completed` fires.                                                                                                                                                                                                                                                                       |
| `POST /v2/sandbox/payouts/:id/simulate-stamp`                 | Records one signer's stamp against a multi-signer payout. Body carries `walletSignerId` and an `outcome` (`approved` or `declined`, default `approved`). Returns the payout in its current state; quorum progress (`collected` / `required`) comes from the `transaction.signature_collected` webhook.                                                                                                                                                         |
| `POST /v2/sandbox/customers/:customerId/simulate-reset-claim` | Wipes the customer's wallet setup (every signer, every wallet, the crypto-wallet feature flag, and, for a custodial account, its server-side signing credentials) so a subsequent `claim-non-custodial` starts fresh. Works for both non-custodial and custodial accounts. Refuses with `409 CLAIM_RESET_BLOCKED` if open transactions, deposits, or pending signature approvals still reference the customer; terminalize them via `simulate/terminal` first. |

## Webhooks

| Topic                                                     | Fires when                                                                                                                                                                                                                                                                                                                                                      |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet_signer.invited`                                   | One per roster member at claim time or `POST /wallet-signers`. Payload carries `verificationUrl` and `expiresAt`.                                                                                                                                                                                                                                               |
| `wallet_signer.added`                                     | Co-emitted with `wallet_signer.invited`; carries the steady-state membership shape (role, credentialType, no URL).                                                                                                                                                                                                                                              |
| `wallet_signer.enrolled`                                  | A signer's credential is recorded and they are now `active`. For a passkey signer added to a live roster this fires when an admin approves the add.                                                                                                                                                                                                             |
| `wallet_signer.enrollment_approved`                       | An admin approved a signer's enrollment. Payload carries `approvedByWalletSignerId` (the approving admin), or `null` when the approver can't be attributed.                                                                                                                                                                                                     |
| `wallet_ceremony.awaiting_admin_approval`                 | A roster change (add, remove, or root-quorum update), a new-wallet ceremony, or a recovery is parked waiting for an admin's approval. Payload carries `type`, `adminVerificationUrl` (the admin approval link, may be `null`), and `expiresAt`.                                                                                                                 |
| `wallet_ceremony.completed`                               | A ceremony (roster change, new wallet, recovery, or threshold change) finished successfully.                                                                                                                                                                                                                                                                    |
| `wallet_ceremony.failed`                                  | A ceremony was declined by the admin, expired, or was auto-refused by a risk check.                                                                                                                                                                                                                                                                             |
| `wallet_signer.removed`                                   | A signer was removed from the roster (direct removal only; demote does not emit `removed`).                                                                                                                                                                                                                                                                     |
| `wallet_signer.promoted`                                  | A signer was promoted to admin after the root-quorum ceremony cleared.                                                                                                                                                                                                                                                                                          |
| `wallet_signer.demoted`                                   | An admin was demoted to signer after the root-quorum ceremony cleared.                                                                                                                                                                                                                                                                                          |
| `transaction.awaiting_signature`                          | Payout parked at the quorum gate. In the passkey signing mode the payload carries `verificationUrl` and `expiresAt` (the same URL also lives on per-signer `wallet_signer.invited` payloads for the enrollment phase); in a programmatic signing mode it carries a `signingRequestId` instead — see [Machine-signer stamping](/guides/machine-signer-stamping). |
| `transaction.signature_collected`                         | One stamp recorded. Payload carries `collected` and `required`. May re-fire if a roster change scrubs a stamp.                                                                                                                                                                                                                                                  |
| `transaction.quorum_met`                                  | Quorum reached; payout proceeds to broadcast.                                                                                                                                                                                                                                                                                                                   |
| `transaction.failed` with `failureCode: "roster_changed"` | A roster change scrubbed enough stamps that the payout can no longer reach quorum on the new roster.                                                                                                                                                                                                                                                            |

## Error codes on the multi-signer surface

| Code                                  | Surface                                         | Meaning                                                                                                                                 |
| ------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `CUSTOMER_ALREADY_CUSTODIAL`          | `claim-non-custodial`                           | The customer already has a custodial account; `claim-non-custodial` only provisions fresh customers.                                    |
| `CUSTOMER_ALREADY_NON_CUSTODIAL`      | `claim-non-custodial`                           | The customer already has a non-custodial wallet from a previous claim.                                                                  |
| `CUSTOMER_KYB_INCOMPLETE`             | `claim-non-custodial`                           | The customer has not yet been KYB-approved.                                                                                             |
| `ROSTER_BELOW_MIN_ADMINS`             | `claim-non-custodial`, `remove`, `demote`       | The proposed roster has fewer than the required minimum of admins.                                                                      |
| `THRESHOLD_EXCEEDS_ROSTER`            | `claim-non-custodial`, `add`, `remove`          | `signingThreshold` cannot exceed the number of `active` signers.                                                                        |
| `SIGNER_EMAIL_DUPLICATE`              | `claim-non-custodial`, `add`                    | Two roster members share an email.                                                                                                      |
| `SIGNER_NOT_FOUND`                    | `remove`, `promote`, `demote`, `simulate-stamp` | The signer id does not exist for the customer.                                                                                          |
| `SIGNER_NOT_ACTIVE`                   | `remove`                                        | Signer is still in `pending_activation` (never enrolled).                                                                               |
| `SIGNER_IS_ROOT_MEMBER`               | `remove`                                        | Removing an admin directly would skip the root-quorum ceremony; demote first.                                                           |
| `SIGNER_ALREADY_ADMIN`                | `promote`                                       | Target signer already holds a root-quorum seat; there is no seat to grant.                                                              |
| `ROOT_AT_CAPACITY_SWAP_REQUIRED`      | `promote`                                       | The root quorum already holds both customer seats; retry with a `demoteSignerId` naming the current root admin to unseat (a seat swap). |
| `DEMOTE_TARGET_NOT_IN_ROOT`           | `promote`                                       | The `demoteSignerId` is not a current member of the root quorum, so there is no root seat to free.                                      |
| `SWAP_WOULD_BREAK_F12`                | `promote`                                       | The seat swap would leave fewer than two admins on the roster. Add or promote another admin first.                                      |
| `SIGNER_NOT_ADMIN`                    | `demote`                                        | Target signer is not currently `admin`.                                                                                                 |
| `CEREMONY_IN_FLIGHT`                  | `add`, `remove`, `promote`, `demote`            | A previous ceremony is still running for this customer; retry after a short backoff.                                                    |
| `WOULD_BREAK_MIN_ADMINS`              | `remove`, `demote`                              | The change would drop the admin count below the minimum floor.                                                                          |
| `QUORUM_THRESHOLD_EXCEEDS_SIGNERS`    | quorum endpoints                                | The requested threshold is greater than the active signer count.                                                                        |
| `QUORUM_WALLET_OVERRIDE_CANNOT_RAISE` | per-wallet quorum override                      | A per-wallet override cannot exceed the customer-default threshold.                                                                     |
| `CLAIM_RESET_BLOCKED`                 | `simulate-reset-claim`                          | Open transactions, deposits, or pending signature approvals still reference the customer; terminalize them first.                       |
| `PROVIDER_ACCOUNT_NOT_FOUND`          | `simulate-reset-claim`                          | The customer has no wallet account to reset.                                                                                            |
