> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meshconnect.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify self-hosted wallets

By the end of this guide, you'll have wallet ownership verification working in your integration, with the signed message payload captured and ready for compliance and audit purposes.

<Check>
  **Before you start**

  * Your core Mesh integration is working end-to-end (see [Prepare for go-live](/build/go-live))
  * You've determined which addresses or wallet types require verification in your app
</Check>

## Background and Overview

Travel Rule regulations have long existed in traditional financial markets as a way for regulators to enforce laws pertaining to the movement of money (eg. anti-money laundering, terrorist financing, sanctions, etc.). In short, regulated financial institutions are responsible for knowing who they are receiving assets from, and where they're sending assets to. The crypto world has long-awaited clear Travel Rule guidelines for crypto, and on July 4th, 2024 the European Banking Authority (EBA) released its final guidelines. Other global regulatory regimes are likely not far behind. This guide dives into detail around Mesh's support for verifying ownership of a self-hosted wallet.

## How does wallet verification work?

The nature of self-hosted wallets is that they don't know who the user is. In other words, a user doesn't KYC with MetaMask like they would with Coinbase or another centralized exchange. So verifying ownership of a self-hosted wallet is not about receiving user information from that wallet. Instead, it is about confirming that the user you know is in control of the wallet in question. Verification pertains to an address (ie. 0x31…cF98), not a wallet app (ie. MetaMask). Keep in mind that a user can interact with the same wallet (ie. address) from multiple wallet apps, and can also interact with multiple wallets from within the same wallet app.

The EBA specifies that one acceptable method of verifying ownership of a self-hosted wallet is having the user sign a self-attestation of ownership (ie. a message) in that wallet. The message doesn't have to be anything specific, but the message the user signs must be the exact message requested by you (or Mesh). A message signature is an off-chain event (ie. it's completely gasless), but is also fully verifiable with the combination of the `signedMessageHash`, the `address`, and `message`.

In Mesh's wallet verification flow, the user is prompted to sign a message immediately after connecting their wallet. Similar to connection requests and transfer requests, the user won't need to configure anything — all they have to do is review and sign. Wallet verification can be completely independent of a transfer (ie. the user only connects their wallet and signs the message), or can be used in combination with a transfer (ie. the user connects their wallet, signs a message, and then continues to the transfer). Bitcoin is the exception — it is verification-only today, and has additional constraints. See [Verify Bitcoin wallets](#verify-bitcoin-wallets).

## How to use wallet verification in Mesh Link

<Note>
  When `verifyWalletOptions.addresses` is provided, `verifyWalletOptions.networkId` is **required**. This prevents dead-end flows where a user connects a wallet that cannot match the requested address. If you only set `verificationMethods` (no `addresses`), `networkId` remains optional.
</Note>

### 1. Use the `verifyWalletOptions` object in the Link Token request

<Accordion title="Link Token request structure">
  <div className="parameter-reference">
    <Accordion title="Parameter reference">
      <div className="param-list">
        <div className="param-row">
          <div className="param-name"><code>X-Client-Id</code> <span className="param-tag param-tag-required">required</span></div>
          <div className="param-body">Your Mesh Client ID.</div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>X-Client-Secret</code> <span className="param-tag param-tag-required">required</span></div>
          <div className="param-body">Your Mesh API Key.</div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>userId</code> <span className="param-tag param-tag-required">required</span></div>
          <div className="param-body">A unique, persistent user identifier. Personally identifiable information such as an email address or phone number should not be used. 300 characters length maximum.</div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>restrictMultipleAccounts</code> <span className="param-tag param-tag-optional">optional</span></div>

          <div className="param-body">
            <p>Defaults to `true`, which is standard used for any transfer flow.</p>
            <p>On some non-transfer flows (ie. "read" use cases), a user could connect multiple accounts in a row if this value were `false`. Sometimes valuable for withdrawal use cases to let the user create an external "address book".</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>integrationId</code> <span className="param-tag param-tag-optional">optional</span></div>

          <div className="param-body">
            <p>A unique Mesh identifier representing a specific integration.</p>
            <p>Use the **Get integrations** endpoint ([/api/v1/transfers/managed/integrations](https://docs.meshconnect.com/api-reference/managed-transfers/get-integrations)) to pull a list of integrations and the corresponding Mesh `integrationId`. These values won't change, so you do not have to hit this endpoint before each Link Token request.</p>
            <p>To be used if the user selects the integration in your UX before launching Mesh (most commonly in an `onramp` flow).</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>verifyWalletOptions.<strong>verificationMethods</strong></code> <span className="param-tag param-tag-optional">optional</span> <span className="param-tag param-tag-required">required for verifications</span></div>

          <div className="param-body">
            <p>Method by which the user must verify their self-hosted wallet.</p>
            <p>Enum to support future verification methods, but only one option for now.</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>verifyWalletOptions.<strong>message</strong></code> <span className="param-tag param-tag-optional">optional</span></div>

          <div className="param-body">
            <p>The message the user should sign in their wallet.</p>
            <p>The exact language isn't important. Mesh has standard language if this isn't provided.</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>verifyWalletOptions.<strong>networkId</strong></code> <span className="param-tag param-tag-optional">optional</span> <span className="param-tag param-tag-required">required when addresses is set</span></div>

          <div className="param-body">
            <p>A unique Mesh identifier for the network on which the user must verify their self-hosted address.</p>
            <p>Use the **Get networks** endpoint ([/api/v1/transfers/managed/networks](https://docs.meshconnect.com/api-reference/managed-transfers/get-networks)) to pull a list of all supported networks and the corresponding Mesh `networkId`. These values won't change, so you do not have to hit this endpoint before each Link Token request.</p>
            <p>To be used if you need your user to verify an address on a specific network. Should not be used in combination with `networkType`.</p>
            <p>Required whenever `addresses` is provided. Optional when `addresses` is omitted.</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>verifyWalletOptions.<strong>networkType</strong></code> <span className="param-tag param-tag-optional">optional</span></div>

          <div className="param-body">
            <p>The network type on which the user must verify their self-hosted address.</p>
            <p>To be used if you need your user to verify an address on a particular network type (eg. `evm`, `solana`, `bitcoin`). Should not be used in combination with `networkId`.</p>
          </div>
        </div>

        <div className="param-row">
          <div className="param-name"><code>verifyWalletOptions.<strong>addresses</strong></code> <span className="param-tag param-tag-optional">optional</span></div>

          <div className="param-body">
            <p>A list of addresses from which the user can verify ownership. User is not allowed to verify an address outside this list.</p>
            <p>Used if you need your user to verify ownership of a specific address. If not provided, user can verify ownership of any self-hosted address they connect.</p>
            <p>When provided, `networkId` is also required.</p>
            <p>For Bitcoin, the address you pin must match the address type the connected wallet is currently exposing — see [Verify Bitcoin wallets](#verify-bitcoin-wallets).</p>
          </div>
        </div>
      </div>
    </Accordion>
  </div>

  ```bash theme={null}
  curl --request POST \
    --url https://sandbox-integration-api.meshconnect.com/api/v1/linktoken \ // This is pointing to sandbox
    --header 'Content-Type: application/json' \
    --header 'X-Client-Id: YOUR_CLIENT_ID' \ // Replace
    --header 'X-Client-Secret: YOUR_API_KEY' \ // Replace
    --data '
  {
    "userId": "UNIQUE_USER_ID", // Replace
    "restrictMultipleAccounts": true,
    "integrationId": "757e703f-a8fe-4dc4-d0ec-08dc6737ad96", // Phantom, replace, optional
    "verifyWalletOptions": {
      "verificationMethods": [
        "signedMessage"
      ],
      "message": "MESSAGE_TO_BE_SIGNED", // Replace
      "networkId": "0291810a-5947-424d-9a59-e88bb33e999d", // Solana, replace — required when `addresses` is provided
      "networkType": "solana", // Replace, optional
      "addresses": [ // Optional
        "xxx" // Replace
      ]
    }
  }
  '
  ```

  **Notes:**

  * Wallet verification is fully compatible with the deposit & payment use cases. If you require users to verify that they own a self-custody wallet before initiating a deposit or payment from that wallet, you can add these configurations into the same Link Token request. Bitcoin is the exception — it is verification-only, and is gated out of transfer, deposit, and funding flows. See [Verify Bitcoin wallets](#verify-bitcoin-wallets).
  * This will only impact the user experience for self-hosted wallets (ie. MetaMask, Phantom, etc.). This will not change anything about the experience for centralized exchanges (ie. Binance, Coinbase, etc.).
</Accordion>

### 2. Store the `walletMessageSigned` payload

After the user signs the message in their wallet, you will receive the `walletMessageSigned` SDK event with the following payload:

* `signedMessageHash`
* `message`
* `address`
* `timeStamp`
* `isVerified` (boolean)

This data can be stored on your side for audit purposes, as well as to improve the return user experience within your UX.

**Note:** This is the only time the `signedMessageHash` will be provided — Mesh does not retain this data.

## Verify Bitcoin wallets

Bitcoin wallet verification is live for **MetaMask**, **OKX Wallet**, and **Bitget Wallet**. Users connect a Bitcoin wallet directly inside Link and cryptographically prove ownership of a Taproot, Native SegWit, or Legacy address — no copy/paste, no QR code. The flow and the `walletMessageSigned` payload are the same as for any other network.

<Warning>
  **Scope of Bitcoin support**

  * **Desktop web only**, on Web SDK `3.12.0` or later. It's on by default for all customers — no feature flag required.
  * **No mobile support.** MetaMask, OKX Wallet, and Bitget Wallet do not support the WalletConnect standard for Bitcoin, which the mobile connection path depends on.
  * **Verification only.** Bitcoin is intentionally gated out of transfer, deposit, and funding flows for these wallets, since they can't yet sign and broadcast a BTC transaction. Unlike EVM or Solana, a Bitcoin verification can't be chained into a transfer in the same session.
</Warning>

### Bitcoin address types are not interchangeable

Native SegWit (`bc1q…`) and Legacy (`1…`) addresses are secured by ECDSA keys. Taproot (`bc1p…`) addresses are secured by Schnorr keys (BIP-340) and derived along a different path (BIP86 vs. BIP44/84). A signature produced for one address type cannot be validated against another, and there is no automatic conversion or fallback between them.

What this means for your integration:

* A verify request must target the exact address type the connected wallet is currently exposing.
* If you pin `verifyWalletOptions.addresses`, the address you pin has to match that type. Pinning a Taproot address while the wallet is serving Native SegWit will fail verification.
* Surface the active Bitcoin address type in your UX, or let the user pick it, before submitting a verify request. Don't assume one default address type works across every connected wallet.

### Configure a Bitcoin verification

Scope the request to Bitcoin with either `networkType: "bitcoin"` or the Bitcoin `networkId` — not both.

This targets production — Bitcoin verification isn't available in sandbox. Replace `YOUR_CLIENT_ID` and `YOUR_API_KEY` with your own credentials.

```bash theme={null}
curl --request POST \
  --url https://integration-api.meshconnect.com/api/v1/linktoken \
  --header 'Content-Type: application/json' \
  --header 'X-Client-Id: YOUR_CLIENT_ID' \
  --header 'X-Client-Secret: YOUR_API_KEY' \
  --data '
{
  "userId": "UNIQUE_USER_ID", // Replace
  "restrictMultipleAccounts": true,
  "verifyWalletOptions": {
    "verificationMethods": [
      "signedMessage"
    ],
    "message": "MESSAGE_TO_BE_SIGNED", // Replace
    "networkType": "bitcoin", // Or "networkId": "03dee5da-7398-428f-9ec2-ab41bcb271da"
    "addresses": [ // Optional — must match the address type the wallet is exposing
      "bc1p..." // Replace
    ]
  }
}
'
```

## Test your implementation

Test using MetaMask (Sepolia testnet) or Phantom (Solana Devnet). See [Sandbox & Testnets](/resources/sandbox-testnets) for wallet setup instructions.

**What to test:**

* **Full flow**: Launch a sandbox Link session with `verifyWalletOptions` configured. Connect a test wallet — the user should be prompted to sign immediately after connecting, with no additional configuration needed.
* **Payload capture**: Confirm your `onEvent` handler receives `walletMessageSigned` with all five fields present: `address`, `isVerified`, `message`, `signedMessageHash`, and `timeStamp`.
* **Message match**: Verify that `message` in the payload exactly matches what you passed in `verifyWalletOptions.message`. If you didn't provide a custom message, confirm Mesh's default language appears.
* **Rejection path**: Reject the signature request in your test wallet (click "Reject" in MetaMask). Confirm you receive the `verifyWalletRejected` event and that your app handles it gracefully.
* **Payload storage**: Confirm you're capturing and storing `signedMessageHash` at the time of the event — it won't be retrievable afterward.

**Testing Bitcoin:** the sandbox does not include a Bitcoin testnet, so BTC verification has to be exercised against production with a real wallet on desktop web. Signing a message is an off-chain, gasless event — no funds move and no transaction is broadcast — so a production verify is safe to run as a test. Repeat the checks above per address type (Taproot, Native SegWit, Legacy) for each wallet you support, since a signature for one type won't validate against another.

## What's next

Explore the other **Extend** guides:

* [Add Mesh onramp integrations to your "Buy" lineup](/extend/onramp) — add exchange-based onramps to your "Buy" flow.
* [Add Mesh to your withdrawal flow](/extend/withdrawal) — enable automated address retrieval for user withdrawals.

***

<Accordion title="AI coding reference (llms.txt)">
  *AI coding reference — a compact summary of this page's APIs, parameters, and patterns for use by AI coding assistants (following the [llms.txt standard](https://llmstxt.org/)). Human readers can safely ignore this.*

  **llms.txt — Verify self-hosted wallets**

  Wallet ownership verification via signed message (EBA Travel Rule compliance). User signs a message in their wallet immediately after connecting; you store the signed payload for audit.

  **Link Token verifyWalletOptions params**:
  `verificationMethods: ["signedMessage"]` (required) | `message` (optional — Mesh provides default if omitted) | `networkId` (required when `addresses` is provided; otherwise optional; do not use with `networkType`) | `networkType` (optional, e.g. "evm", "solana", "bitcoin") | `addresses[]` (optional — restrict to specific addresses)

  **Constraint**: `verifyWalletOptions.networkId` is required whenever `verifyWalletOptions.addresses` is set. `networkId` remains optional when `addresses` is omitted.

  **Get networkIds**: Use GET /api/v1/transfers/managed/networks to fetch the full list of supported networks and their networkId values. These values don't change — safe to cache permanently, no need to call this endpoint before every Link Token request.

  **Compatible with transfer flows**: Add `verifyWalletOptions` to any deposit or payment Link Token request. User verifies wallet then proceeds to transfer. Does not affect CEX flows. Exception: Bitcoin is verification-only and cannot be chained into a transfer.

  **Bitcoin verification**: Live for MetaMask, OKX Wallet, Bitget Wallet. Desktop web only, Web SDK 3.12.0+, on by default (no feature flag). No mobile support — these wallets don't support the WalletConnect standard for Bitcoin. BTC is gated out of transfer/deposit/funding flows for these wallets.
  Scope with `networkType: "bitcoin"` or `networkId: "03dee5da-7398-428f-9ec2-ab41bcb271da"` (not both).
  Address types are NOT interchangeable: Native SegWit (`bc1q…`) and Legacy (`1…`) use ECDSA; Taproot (`bc1p…`) uses Schnorr/BIP-340 on a different derivation path (BIP86 vs BIP44/84). A signature for one type never validates against another; no conversion or fallback. If pinning `addresses[]`, the pinned address must match the type the connected wallet is currently exposing.
  Testing: no Bitcoin testnet in sandbox — test against production. Message signing is off-chain and gasless, so no funds move.

  **SDK event: walletMessageSigned**
  Payload: `signedMessageHash` | `message` | `address` | `timeStamp` | `isVerified` (boolean)
  Critical: Store `signedMessageHash` at event time — Mesh does not retain it.

  **Rejection event**: `verifyWalletRejected` fires if user declines to sign.

  **Test**: MetaMask (Sepolia testnet) or Phantom (Solana Devnet). See Sandbox & Testnets guide for setup.

  **Verify use case — Link Token request body**:

  ```javascript theme={null}
  curl --request POST \
    --url https://sandbox-integration-api.meshconnect.com/api/v1/linktoken \ // This is pointing to sandbox
    --header 'Content-Type: application/json' \
    --header 'X-Client-Id: YOUR_CLIENT_ID' \ // Replace
    --header 'X-Client-Secret: YOUR_API_KEY' \ // Replace
    --data '
  {
    "userId": "UNIQUE_USER_ID", // Replace
    "restrictMultipleAccounts": true,
    "integrationId": "757e703f-a8fe-4dc4-d0ec-08dc6737ad96", // Phantom, replace, optional
    "verifyWalletOptions": {
      "verificationMethods": [
        "signedMessage"
      ],
      "message": "MESSAGE_TO_BE_SIGNED", // Replace
      "networkId": "0291810a-5947-424d-9a59-e88bb33e999d", // Solana, replace — required when `addresses` is provided
      "networkType": "solana", // Replace, optional
      "addresses": [ // Optional
        "xxx" // Replace
      ]
    }
  }
  '
  ```

  **`walletMessageSigned` event payload** (capture immediately — Mesh does not store it):

  ```json theme={null}
  {
    "address": "0xABC...",
    "isVerified": true,
    "message": "I confirm I own this wallet address.",
    "signedMessageHash": "0xDEF...",
    "timeStamp": "2024-07-04T12:00:00Z"
  }
  ```
</Accordion>
