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

# Non-Custodial Wallets

> How non-custodial crypto wallets work — the customer controls the key and must approve every outbound transfer

## Overview

For sandbox lifecycle prose + copy-paste recipes (cosign approved and declined), see [`withdrawals.mdx` Step 4](/sandbox/withdrawals#step-4-resolve-the-cosign-gate).

Conduit supports two custody models for crypto wallets:

| Model             | Who controls the key        | How funds are moved                                                             |
| ----------------- | --------------------------- | ------------------------------------------------------------------------------- |
| **Custodial**     | Conduit                     | Conduit signs and broadcasts autonomously                                       |
| **Non-custodial** | Customer (end-user passkey) | Conduit and the customer co-sign; Conduit broadcasts after both signatures land |

In the non-custodial model, every customer has a dedicated key set that requires two cryptographic signatures to move funds: one from Conduit, and one from the customer. The customer's signature is generated on-device using a WebAuthn passkey — Conduit never sees the private key. Conduit alone cannot move non-custodial funds.

## Signing modes

The customer's signature doesn't have to come from a human. Every customer has a **signing mode**, configured by Conduit out of band as an organization-wide default with per-customer overrides (the customer-level setting wins) — ask your Conduit representative; there is no API to set it.

| Mode                        | Who signs payouts                                            | Who governs the roster                                                       |
| --------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| **Passkey required**        | Human passkey holders; `api_key` roster members are rejected | Passkey admins                                                               |
| **Programmatic**            | Machine (API-key) signers may hold `role: "signer"` and vote | Admins must still be passkey holders                                         |
| **Programmatic unattended** | Machine signers, fully server-driven                         | Machine keys may be admins (enabled only after an additional Conduit review) |

**Everything below on this page — the verify page, WebAuthn, `verificationUrl` — describes the passkey-required mode.** In a programmatic mode, machine signers seat `active` at claim time with no enrollment step, the `transaction.awaiting_signature` webhook carries a `signingRequestId` (plus a `verificationUrl` only when the roster has an active passkey signer, who may approve on the verify page alongside the machine signers), and your backend reads the request via `GET /v2/signing-requests/{id}` and resolves it via `POST /v2/signing-requests/{id}/approve` or `.../reject`. Conduit's compliance co-approval is still always required — a machine can never move money alone. See [Programmatic payout signing](/guides/machine-signer-stamping).

## Enabling crypto for a customer

Before a customer can claim a non-custodial wallet roster, they need an approved `CRYPTO_WALLET` feature on file:

```
POST /v2/customers/:id/features  { "type": "crypto_wallet" }
       │
       ▼
 Application enters the review queue:
   • In sandbox the default is auto-approve (status: "approved" returned synchronously)
   • In live the default is manual review (status: "processing") until ops approves
   • Customers in restricted jurisdictions get 422 CRYPTO_NOT_AVAILABLE_IN_JURISDICTION
       │
       ▼
 Once the application's status is "approved", call claim-non-custodial.
 Calling claim-non-custodial before the feature is approved returns 422 CRYPTO_FEATURE_NOT_APPROVED.
```

The review gate is per-customer, distinct from the customer's KYB. A KYB-approved customer is not automatically crypto-enabled — feature approval is a separate decision that can also flag tier/plan gating, additional crypto-specific risk review, or per-bank-partner restrictions.

## Two-signature model

Moving funds from a non-custodial wallet always requires both:

1. **The customer's signature** — produced when the customer approves on the Conduit-hosted verify page using their passkey (Face ID, Touch ID, or a hardware security key).
2. **Conduit's compliance signature** — applied automatically once the customer quorum is collected. Compliance and Travel Rule screening already ran *before* the signature was requested, so this final stamp applies that cleared result rather than re-deciding it — a payout that passed screening is not re-screened after signing. Conduit cannot broadcast without this stamp.

Both signatures must land before Conduit broadcasts. The security guarantee is cryptographic, not application-level: there is no code path in which Conduit can bypass the customer's signature requirement, and no code path in which a payout that failed compliance can be signed.

## Withdrawal Flow

A non-custodial payout is screened for compliance and Travel Rule **first**. Only once it clears does Conduit request the customer's signatures (`transaction.processing` fires and the payout's status becomes `processing`). After the quorum is collected, Conduit adds its own compliance stamp and broadcasts. Because screening runs before signing, a payout that fails compliance is rejected before any signer is asked to approve — the customer never signs a payment that then fails screening.

```
POST /v2/payouts  →  202 Accepted (status: "pending")
       │
       ▼
 Compliance + Travel Rule screening — no client action.
 A payout that fails here is rejected before any signature is requested.
       │
       ▼
 transaction.processing fires (status becomes "processing").
 Payout enters the per-(wallet, chain) signing queue
 — it may report a queuePosition while it waits its turn.
       │
       ▼
 transaction.awaiting_signature fires
       • data.verificationUrl  ← route the customer here
       • data.expiresAt        ← sign before this time
       • data.attempt          ← increments if the window is rebuilt
       │
       ▼
 Customer approves on the verify page (passkey).
 Quorum met (M business-signer stamps collected).
       │
       ▼
 Conduit adds its compliance stamp and broadcasts on-chain.
       │
       ▼
 transaction.completed fires when the chain confirms.
```

If the customer doesn't approve in time, or declines, `transaction.failed` fires with the matching `failureCode` (see [Failures](#failures) below).

## Verify Page

The verify page is a Conduit-hosted UI served at `https://app.conduit.financial/verify/<token>`. It is:

* **Branding-aware** — displays your organization's logo and colors.
* **Information-rich** — shows the transfer asset, amount, and destination address so the customer knows exactly what they are approving.
* **WebAuthn-native** — uses the customer's registered passkey (no password, no SMS OTP).

### Flow

1. Your backend receives `transaction.awaiting_signature` with `verificationUrl` on the webhook payload.
2. Route your customer to that URL (deep link, redirect, or in-app WebView).
3. The customer taps "Approve" — their device presents a biometric prompt (Face ID, Touch ID, or hardware key).
4. Conduit receives the passkey approval and resumes the payout.
5. The customer sees a confirmation screen. The payout proceeds to broadcast.

### Branding Customization

The verify page respects your organization's branding configuration (logo URL, primary color). Contact Conduit support to configure or update your branding.

## Timeout and Decline Behavior

| Scenario                                     | What happens                                                                                                            | Customer-facing message                                                      |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Customer approves within the signing window  | Withdrawal proceeds to compliance checks, then broadcast                                                                | Confirmation screen on the verify page                                       |
| Signing window expires (attempts remaining)  | Conduit rebuilds the request with a fresh link; `transaction.awaiting_signature` re-fires with an incremented `attempt` | Route the customer to the new `verificationUrl`                              |
| All signing attempts exhausted               | Payout fails; no funds moved; `transaction.failed` fires with `failureCode: "user_signature_expired"`                   | "The signing window expired. Start a new payout when the customer is ready." |
| Customer clicks "Decline" on the verify page | Payout fails; no funds moved; `transaction.failed` fires with `failureCode: "user_signature_declined"`                  | "The customer declined the payout."                                          |
| Invalid passkey stamp (retryable)            | Verify page shows an error; customer can retry                                                                          | Verify page error — same session, no new payout needed                       |

The signing deadline for each attempt is the `expiresAt` delivered on the `transaction.awaiting_signature` event — always drive your timers off that value, never a fixed duration. The window is not 15 minutes: in production it runs up to roughly 23 hours on EVM chains and 12 hours on Tron. The link-expiry (`verificationUrl`) and the signing timer are separate — the signing timer is the one that governs the payout, and it is the `expiresAt` on the webhook. This window and its rebuild behaviour are the same for a wallet in a programmatic signing mode: a machine request also carries an `expiresAt`, is rebuilt with an incremented `attempt` (a new `signingRequestId`) when the window closes, and fails with `user_signature_expired` only after the final attempt. Always treat the latest `attempt` as authoritative and discard prior links.

## Webhook Integration

Subscribe to `transaction.awaiting_signature` to know when a non-custodial payout is awaiting approval. See the [Webhooks reference](/webhooks#transactionawaiting_signature) for the full payload schema.

<Note>
  The `verificationUrl` on this webhook applies to a wallet in the **passkey** signing mode — the human-signer, verify-page model this page describes. A wallet in a **programmatic** signing mode carries a `signingRequestId` on the same webhook — plus an optional `verificationUrl` when its roster has an active passkey signer who may also approve on the verify page; a machine integration reads the request via `GET /v2/signing-requests/{signingRequestId}` and resolves it via `POST .../approve` or `.../reject`. See [Machine-signer stamping](/guides/machine-signer-stamping).
</Note>

Recommended flow on your backend:

```
Receive transaction.awaiting_signature
   │
   ├── Store verificationUrl and expiresAt (from the webhook payload) against the transactionId
   │
   └── Notify the customer (push notification, email, in-app alert)
         → Deep-link to verificationUrl before expiresAt
```

The verify URL and expiry are delivered only on the webhook — they are not exposed on `GET /v2/payouts/:id`. Persist the webhook payload (or its essential fields) so you can surface the verify URL to the customer on demand.

## `GET /v2/payouts/:id` Response

While a payout is awaiting the customer's signature, the response continues to report `requiresUserSignature: true` and, if the payout is queued behind earlier signing work on the same wallet+chain, a `queuePosition`:

```json theme={null}
{
  "id": "txn_2xKjF9mQb7vN4hL1pR3w8t",
  "status": "pending",
  "requiresUserSignature": true,
  "queuePosition": 2
}
```

`queuePosition` is the payout's slot in the per-(wallet, chain) signing queue. **Waiting payouts** (queued behind a payout already in the signing step) expose this as a positive integer. The **active payout** (currently being signed) has the field absent from the response. The field is also absent once the payout advances past the queue gate or reaches a terminal status. The value is eventual — it may not appear on the initial `POST` response; read it via `GET /v2/payouts/:id`.

`requiresUserSignature` is a present-tense gate: `true` while the payout is awaiting the end-user's signature, `false` once `status` reaches `completed`, `failed`, or `cancelled`. It is also present on the `POST /v2/payouts` response (derived from the wallet's custody model at submission) so your frontend can immediately prepare the user-routing flow without waiting for the webhook. After the response goes terminal, expect `false` regardless of the source wallet's custody model — don't keep waiting on a settled row.

## Sandbox Testing

In the sandbox environment, the real signing flow is bypassed. Resolve the cosign gate via one of two endpoints. They produce the same outcome a real customer action on the verify page would, and the payout reaches `completed` or `failed` automatically — no follow-up `simulate/confirm` is required.

### Simulate endpoints

**Recommended: resolve by payout ID** — most integrators should use this variant. Drive any specific payout end-to-end keyed by the `id` returned from `POST /v2/payouts`. Each call casts one signer's vote: omit `signerId` and repeated calls auto-advance through the roster one signer at a time until the threshold is met, or pass a `signerId` to cast a specific signer's vote. A single `declined` vote terminalizes the whole quorum as declined.

```bash theme={null}
POST /v2/sandbox/payouts/:id/simulate/cosign
Content-Type: application/json

{ "outcome": "approved" | "declined", "signerId": "wsg_..." }
```

Returns `200` with the payout at its current state; `signerId` is optional. Calling again after the gate has already resolved is safe — it returns the payout at its current state rather than an error.

**Per-signer variant** — records one named signer's stamp against a payout, so a test can walk quorum progress signer by signer (while the signing request is still pending, re-stamping by the same signer is idempotent):

```bash theme={null}
POST /v2/sandbox/payouts/:id/simulate-stamp
Content-Type: application/json

{ "walletSignerId": "wsg_...", "outcome": "approved" | "declined" }
```

Returns `200` with the payout at its current state; `404` if the payout is not awaiting a signature or the signer is not on the wallet's roster. Note the contrast with `simulate/cosign` above: once the quorum has resolved, a repeat call here returns `404 NOT_IN_AWAITING_SIGNATURE` rather than the payout's current state.

See the [Multi-signer wallets recipe](/sandbox/multi-signer-wallets) and the [Custody sandbox guide](/sandbox/custody) for full walkthroughs.

## Failures

Once `POST /v2/payouts` returns `202 Accepted`, any failure during the signing flow arrives on the `transaction.failed` webhook. When the failure has a cause your integration can act on, the payload carries a `failureCode`:

| `failureCode`                         | What happened                                                                                                     | What to do                                                                                                                        |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `user_signature_timeout`              | The payout waited too long in the signing queue without reaching the signing step (queue max-residence timeout).  | No funds were moved. Submit a new payout when the customer is ready to sign.                                                      |
| `user_signature_expired`              | Every signing window was offered to the customer but quorum was not reached; all rebuild attempts were exhausted. | No funds were moved. Submit a new payout when the customer is ready to sign.                                                      |
| `user_signature_declined`             | The customer declined the payout from the approval page.                                                          | No funds were moved. Submit a new payout if the decline was unintentional.                                                        |
| `user_signature_rejected_by_provider` | The customer's passkey approval could not be accepted.                                                            | No funds were moved. Submit a new payout. If the same customer or wallet hits this repeatedly, contact support.                   |
| `crypto_wallet_misconfigured`         | The wallet's configuration prevents Conduit from moving funds from it.                                            | No funds were moved. Conduit is investigating automatically. Contact support if the wallet is needed for a time-sensitive payout. |

The webhook `reason` field is a human-readable version of the `failureCode`. The same `failureCode` is surfaced on `GET /v2/transactions/:id` and `GET /v2/payouts/:id`, so a fresh GET reconciles 1:1 with the webhook.

If `transaction.failed` arrives without a `failureCode`, the payout could not be completed and the cause is not something your integration can act on. Treat the transaction as terminal and contact support if the customer needs help understanding why.
