Core ConceptsWallets

Wallets

Non-custodial stablecoin wallets for your customers, funded by a plain on-chain send, with balances read from the chain itself.

What a wallet is

A wallet is one customer's address on one chain. A customer holds as many as you give them, each with its own address.

Three properties follow from that design:

  • Wallets are non-custodial. We hold no key that can move your customers' funds. Neither does any third party we rely on.
  • A wallet is funded by sending to its address. There is no transfer object to create first. Anyone can pay your customer by pasting the address into any wallet app, and the funds are spendable when they land.
  • Balances are read from the chain, not from a ledger we keep. What the chain says is what the API returns, which is the only way the point above can be true.

If our API, our database, our infrastructure keys, or any of our providers were fully compromised, an attacker still could not move a single token out of one of these wallets. Moving funds requires a signature from a key only you hold.

That protection has a price, and you should decide it is worth paying before you build: we cannot send on your behalf. Every send needs your key, live, at the moment of sending. See Sending Funds. If your signing service is down, sends stop. Deposits, balances, and history are unaffected.

Your two keys

Because wallets derive from your identity, you register two keys before you can create one. Both are yours, both are ed25519, and we only ever see the public halves.

KeyWhere it belongsWhat it does
Primary Signing KeyHot signing service (KMS or HSM)Authorizes day-to-day sends. Addresses never depend on it
Recovery KeyCold storage, offlineIs your permanent identity. Your wallet addresses derive from it, and it is the emergency authority that installs or replaces the Primary Signing Key

The Recovery Key is permanent and unrecoverable. Every wallet address you hold derives from it, so unlike the Primary Signing Key it cannot be rotated. Losing it means losing the ability to install a new Primary Signing Key. Generate it offline, back it up like a root of trust, and keep it out of the system that signs day to day.

You register both public halves on the dashboard's Wallets page, once per environment. We never ask for, accept, or store a private key. The two keys must be different, and sandbox and live each need their own pair: a key that already serves one environment is refused for the other (422 key_reused_across_environments).

Registration happens once per environment. Re-pointing an identity would leave every existing wallet derived from the old one, so changing it later is deliberate operational work.

Activation: one ceremony, ever

Registering keys turns wallet creation on. Sending needs one more thing: your Primary Signing Key must be activated, a single on-chain step that only your Recovery Key can authorize. On the Wallets page you prepare the activation, verify it, sign its digest with the cold key, and submit the signature. The full procedure is in the Signing Guide.

Until it completes, wallets create and receive deposits normally, and creating a send returns 400 activation_required.

The division of authority is the point: the hot key spends, and only the cold key decides which hot key exists.

Creating a wallet

Two gates apply, one on the customer and one on you:

  • At least one approved endorsement, otherwise 400 endorsement_required. Endorsements are requested explicitly, so if you never requested one, none is running.
  • Your two public keys must be registered, otherwise 400 wallet_keys_required. With no identity there is nothing to derive an address from.
curl -s $BASE/api/customers/cus_.../wallets \
  -H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" -d '{
    "chain": "solana",
    "name": "Operating wallet",
    "partner_reference_id": "wallet-4471"
  }'
{
  "id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
  "object": "wallet",
  "customer_id": "cus_033y1FhTOmoW9W2jAuSvQx",
  "chain": "solana",
  "network": "mainnet",
  "address_wallet_id": null,
  "address": "3Nq8vT7YbCk1RjvFhcHc3mYwBSy8PmZ6LcQ5tXhr9Aur",
  "status": "active",
  "custody": "non_custodial",
  "signing_reference": "wallet/wal_0K3y2GhUPnpX0X3kBvTwRy/solana",
  "name": "Operating wallet",
  "partner_reference_id": "wallet-4471",
  "created_at": "2026-08-26T09:40:00+00:00"
}
FieldNotes
chainRequired. See Coverage for what is live
networkmainnet on live, testnet in sandbox. chain reads the same in both
nameDisplay label, and not unique. Use partner_reference_id as your handle
partner_reference_idYour own id, unique per environment. List endpoints filter on it
custodyAlways non_custodial. Stated on every read because it is a property you integrate against
signing_referenceThe wallet's immutable derivation reference. Store it at creation: your verifier compares against it on every send
address_wallet_idReuses one of the same customer's EVM wallets, giving the new wallet that address on another EVM chain. EVM only, so it has no use at launch

No key is provisioned anywhere: the address is derived arithmetic. Each call mints a new wallet with a new address, including on a chain the customer already has.

Solana is the launch chain. The EVM chains are built and enable next, and a chain that is not enabled returns 400 unsupported_chain.

Reading balances

curl -s $BASE/api/wallets/wal_0K3y... -H "Api-Key: $KEY"

The response is the wallet object shown above, plus a balances array:

{
  "balances": [
    {"object": "balance", "asset": "usdc", "chain": "solana", "network": "mainnet", "amount": "1250"},
    {"object": "balance", "asset": "usdt", "chain": "solana", "network": "mainnet", "amount": "0"}
  ]
}

Every supported asset on the chain comes back, zeros included, so you can render a full table without a second call.

Only the single-wallet GET carries balances. The list endpoints return the same wallet objects without it, so list pages stay cheap. For a roll-up across every wallet you hold, use GET /api/wallets/total_balances.

History

curl -s "$BASE/api/wallets/wal_0K3y.../history?limit=50" -H "Api-Key: $KEY"

Entries are wallet_transaction objects, the wallet's activity log:

{
  "id": "wtx_033y7NoPqRsTuVwXyZaBc",
  "object": "wallet_transaction",
  "wallet_id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
  "kind": "deposit",
  "asset": "usdc",
  "chain": "solana",
  "network": "mainnet",
  "amount": "500",
  "source": {"kind": "address", "address": "9Wc4Kx1s5tKUxCnRr8LmVh7pQnWq2ZbEeT4gY6dFhJmN"},
  "destination": {
    "kind": "wallet",
    "wallet_id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
    "address": "3Nq8vT7YbCk1RjvFhcHc3mYwBSy8PmZ6LcQ5tXhr9Aur"
  },
  "tx_hash": "2VfL...",
  "created_at": "2026-08-26T09:44:10+00:00"
}
kindMeaning
depositFunds arrived at the address, including sends we never initiated
withdrawalFunds left the wallet through an authorized send
returnA deposit was sent back

History is an activity log, not the source of truth for balances. Read balances from the wallet object.

Listing

EndpointReturns
GET /api/customers/{customer_id}/walletsOne customer's wallets
GET /api/walletsEvery wallet you hold, filterable by customer_id and partner_reference_id
GET /api/wallets/{wallet_id}One wallet, with balances
GET /api/wallets/total_balancesRoll-up across all wallets
GET /api/wallets/{wallet_id}/historyActivity log, cursor paginated

Statuses

A wallet is active or closed (frozen is reserved). A send from a wallet that is not active returns 400 wallet_not_active. For the full table, see Statuses & Lifecycles.

Events

EventFires when
wallet.createdA wallet was created
wallet_transaction.createdFunds arrived at the address, including sends you did not initiate (kind: "deposit"), or left it (kind: "withdrawal")
wallet_transaction.returnedA deposit was returned
wallet_operation.completedA send you authorized landed
wallet_operation.failedA send you authorized did not land. Nothing moved

wallet_transaction.created with kind: "deposit" is the one to subscribe to first. Because a wallet is funded by a plain on-chain send, it is how you learn a customer was paid: there is no API call to observe instead.