Skip to main content

Overview

For sandbox lifecycle prose + copy-paste recipes (cosign approved and declined), see withdrawals.mdx Step 4. Conduit supports two custody models for crypto wallets:
ModelWho controls the keyHow funds are moved
CustodialConduitConduit signs and broadcasts autonomously
Non-custodialCustomer (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.

Two-signature model

Moving funds from a non-custodial wallet always requires both:
  1. Conduit’s signature — applied automatically once compliance and Travel Rule checks pass.
  2. 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).
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.

Withdrawal Flow

Compliance and Travel Rule data exchange complete before the customer is prompted. The customer is never asked to sign a transaction that has not already passed screening.
POST /v2/payouts  →  202 Accepted (status: "pending")


 Compliance + Travel Rule complete in the background — no client action.


 transaction.awaiting_user_signature fires
       • data.verificationUrl  ← route the customer here
       • data.expiresAt        ← sign before this time


 Customer approves on the verify page (passkey).


 Conduit 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 below).

Verify Page

The verify page is a Conduit-hosted UI served at https://web.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_user_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

ScenarioWhat happensCustomer-facing message
Customer approves within the signing windowWithdrawal proceeds to broadcastConfirmation screen on the verify page
Customer does not approve within the signing window (default 15 min)Payout fails; no funds moved; transaction.failed fires with failureCode: "USER_SIGNATURE_TIMEOUT"”The signing window expired. Start a new payout when the customer is ready.”
Customer clicks “Decline” on the verify pagePayout 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 retryVerify page error — same session, no new payout needed
The signing window is 900 seconds (15 minutes) by default. The signing timer and the verification token TTL share the same value — both expire together.

Webhook Integration

Subscribe to transaction.awaiting_user_signature to know when a non-custodial payout is awaiting approval. See the Webhooks reference for the full payload schema. Recommended flow on your backend:
Receive transaction.awaiting_user_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:
{
  "id": "txn_2xKjF9mQb7vN4hL1pR3w8t",
  "status": "pending",
  "requiresUserSignature": true,
  "queuePosition": 0
}
queuePosition is the payout’s slot in the per-(wallet, chain) signing queue (0 = head of queue / signing now). It is absent once the payout is no longer on the user-signature path or has reached a terminal status. 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 sandbox mode (staging-sandbox / production-sandbox), 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:
POST /v2/sandbox/payouts/:id/simulate-user-signature
Content-Type: application/json

{ "outcome": "APPROVED" | "DECLINED" }
Advanced: resolve by wallet + activity ID — the wallet-keyed variant exists for advanced tests that already track cosign activity IDs through other channels (for example, a flow that issues multiple cosign activities against the same wallet and resolves them out of band). Most integrators should use the payout-ID variant above.
POST /v2/sandbox/wallets/:walletId/simulate-cosign-complete
Content-Type: application/json

{ "activityId": "<cosign activity id>", "outcome": "APPROVED" | "DECLINED" }
Both return 204 on success. Repeating the call after the activity has been resolved returns 409.

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:
failureCodeWhat happenedWhat to do
USER_SIGNATURE_TIMEOUTThe customer did not approve the payout before the signing window expired (default 15 minutes).No funds were moved. Submit a new payout when the customer is ready to sign.
USER_SIGNATURE_DECLINEDThe 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_PROVIDERThe 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_MISCONFIGUREDThe 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.