Self-Hosted Wallets

A self-hosted wallet (also called a non-custodial or unhosted wallet) is a crypto wallet whose private keys are controlled directly by its holder — for example MetaMask, Trust Wallet, or a hardware wallet — rather than by an exchange or custodial provider.

To comply with the crypto Travel Rule, payouts to self-hosted wallets require proof that the declared holder actually controls the destination wallet. This page explains how to register self-hosted wallet recipients and collect that wallet ownership proof.

Register a self-hosted wallet recipient

Create the recipient with details.isNonCustodialWallet: true. When the wallet belongs to a third party (belongsToBeneficiary: false), the recipient's physical address is also required. See Create Recipients for the full field reference and examples.

Wallet ownership proofs

Before a self-hosted wallet recipient can receive payouts, a valid ownership proof must exist for its wallet address. Without one, simulations and transactions targeting the recipient fail with HTTP 422 and error code travel-rule.ownership_proof_required.

Two proof methods are supported:

MethodHow it worksBest for
SIGNATUREThe wallet signs an attestation message with its private key; the proof is verified instantly.Interactive flows where the wallet holder is present (browser wallet, WalletConnect).
MICROTRANSFERThe wallet holder sends a small test transfer of an exact amount from the wallet being proven; verified when it arrives.Wallets that cannot sign arbitrary messages (some hardware or exchange setups).

A SIGNATURE proof can be collected interactively with the SafeConnect components (Option A) or by having the wallet holder sign the attestation in their own tooling (Option B).

A proof is keyed to the wallet address: one valid proof covers every recipient using that address.

Option A: signature proof with the SafeConnect components

The easiest way to collect a signature proof in your frontend is with Notabene's SafeConnect wallet components, which handle wallet connection and message signing. First, issue a short-lived delegate token scoped to the recipient:

curl --location --request POST 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs/delegate-token' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}' \
--header 'Content-Type: application/json' \
--data '{
  "recipientId": "{{RECIPIENT_ID}}"
}'
{
  "accessToken": "eyJhbGciOi...",
  "tokenType": "Bearer",
  "delegateDid": "did:key:..."
}

Pass the accessToken to the SafeConnect widget in your frontend. The wallet holder connects their wallet and signs the attestation; the widget outputs the proof. Then submit it:

curl --location --request POST 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}' \
--header 'Content-Type: application/json' \
--data '{
  "recipientId": "{{RECIPIENT_ID}}",
  "type": "eip-191",
  "proof": "0x_SIGNATURE",
  "attestation": "{{SIGNED_MESSAGE}}",
  "walletProvider": "metamask"
}'

You can also assemble the request yourself from an equivalent raw wallet signature — use type: "eip-191" for EVM wallets (ETH) and type: "tip-191" for TRON wallets. A valid submission returns the proof with status: "VERIFIED"; an invalid signature is rejected with HTTP 400 and error code travel-rule.proof_invalid.

Option B: manual signature

For wallets that cannot use the SafeConnect components — keystore or seed-phrase holders, MPC/custody consoles, and TRON wallets — request the attestation text to sign:

curl --location --request POST 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs/manual-attestation' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}' \
--header 'Content-Type: application/json' \
--data '{
  "recipientId": "{{RECIPIENT_ID}}"
}'
{
  "attestation": "I certify that Ethereum account 0x71c7656e... belonged to did:pkh:eip155:1:0x71c7656e... on Mon, 14 Sep 2026 12:00:00 GMT.",
  "type": "eip-191",
  "walletAddress": "0x71C7656E..."
}

The wallet holder signs the attestation text exactly as returned with the wallet's own key, using any personal_sign-capable tooling — a wallet app's "Sign message" feature, cast wallet sign with a keystore file or private key, or a custody console's message signing. Then submit the signature through the regular proof submission, passing the returned type:

curl --location --request POST 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}' \
--header 'Content-Type: application/json' \
--data '{
  "recipientId": "{{RECIPIENT_ID}}",
  "type": "eip-191",
  "proof": "0x_SIGNATURE",
  "attestation": "{{ATTESTATION_TEXT}}",
  "walletProvider": "manual"
}'

The signature is validated cryptographically: it must be produced by the key of the recipient's walletAddress, over the exact attestation text. A mismatch is rejected with HTTP 400 and error code travel-rule.proof_invalid.

📘

Manual signing applies to externally owned accounts only — smart-contract accounts (for example Safe) cannot produce a personal_sign signature. Use the microtransfer test for those wallets.

Option C: microtransfer (Satoshi test)

Start a test and relay the returned instructions to the wallet holder:

curl --location --request POST 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs/microtransfer' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}' \
--header 'Content-Type: application/json' \
--data '{
  "recipientId": "{{RECIPIENT_ID}}"
}'
{
  "id": "5f2d6c1e-...",
  "recipientId": "{{RECIPIENT_ID}}",
  "walletAddress": "0x71C7656E...",
  "chain": "ETH",
  "method": "MICROTRANSFER",
  "status": "PENDING",
  "expectedAmount": 1.23,
  "expectedCurrency": "USDC",
  "destinationAddress": "0xA0b86991...",
  "expiresAt": "2026-09-04T12:00:00Z"
}

The wallet holder must send exactly expectedAmount expectedCurrency to destinationAddress, from the wallet address being proven, before expiresAt. The proof is verified automatically when the deposit arrives — no further API call is needed. Calling the endpoint again while a test is active returns the same pending test.

📘

The destinationAddress is the beneficiary's own Caliza wallet: the test amount is credited to the beneficiary's balance like any other deposit — it is not refunded to the sending wallet.

Check the current proof

curl 'https://api.sandbox.caliza.com/core-api/v1/travel-rule/proofs?recipientId={{RECIPIENT_ID}}' \
--header 'Authorization: Bearer {{ACCESS_TOKEN}}'

Returns the current proof covering the recipient's wallet address: the VERIFIED proof, or the PENDING microtransfer test with its transfer instructions. Returns 404 when neither exists.

Proof lifecycle

StatusMeaning
PENDINGA microtransfer test is awaiting its on-chain deposit.
VERIFIEDThe proof is valid — payouts to the wallet can be created until expiresAt.
EXPIREDThe proof's validity window has passed. Collect a new proof.
INVALIDATEDThe recipient's wallet or Travel Rule details changed after the proof was collected. Collect a new proof.
📘

Proofs have a validity window (expiresAt, currently one year from verification). Check the current proof before initiating payouts to a self-hosted wallet, and be prepared to re-collect a proof when it expires or the recipient's details change.

Errors

HTTPCodeWhen
422travel-rule.ownership_proof_requiredA simulation or transaction targets a self-hosted wallet recipient without a valid proof.
400travel-rule.proof_invalidThe submitted signature does not prove ownership of the recipient's wallet address.
404—GET /v1/travel-rule/proofs found no verified proof or pending test for the recipient's address.

Related Articles


Did this page help you?