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 things follow, and all of them are deliberate:

  • 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. Rotate it freely: addresses never change
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

There is no third key and no optional setup step. Your identity and your emergency authority are the same secret, which is what keeps the model to two.

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 signing key. Generate it offline, back it up like a root key, and keep it out of the system that signs day to day.

Registration is self-serve on the dashboard:

POST /dashboard/partner/wallet_keys
{"recovery_key": "ed25519:...", "primary_signing_key": "ed25519:..."}

Both values are public halves. We never ask for, accept, or store a private key, and the two must be different keys. Registration happens once: re-pointing an identity would leave every existing wallet derived from the old one, so it returns 409 keys_already_registered and is deliberate ops work.

GET /dashboard/partner/wallet_keys answers "can I create wallets yet", and is valid before registration too:

{
  "object": "wallet_keys",
  "signing_identity": "9f1c...",
  "primary_signing_key": "ed25519:...",
  "wallets_enabled": true,
  "activation_required": ["fund_identity", "install_signing_key"],
  "sending_enabled": false
}

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. The registration response carries the activation envelope, and POST /dashboard/partner/wallet_keys/activation re-prepares it at any time. You verify it, sign with the cold key, and return it to POST /dashboard/partner/wallet_keys/activation/authorize. 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

Three gates, and the first two are on the customer:

  • Verification must be approved, otherwise 400 verification_required.
  • 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 public_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",
  "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
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_idReuse another wallet's derivation to get the same address on a different chain

Creation is instant and involves no third party. The address is derived arithmetic, so there is no provisioning call that could fail or lag. 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 anything not enabled returns 400 unsupported_chain.

One address across EVM chains

One EVM derivation yields the same address on every EVM chain. To extend an existing wallet's address onto another chain, name it:

curl -s $BASE/api/customers/cus_.../wallets \
  -H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"chain": "polygon", "address_wallet_id": "wal_0K3y..."}'

Rules worth knowing:

  • The wallet you name must belong to the same customer, otherwise 400 wallet_not_owned.
  • EVM only, on both sides. Solana keys are not EVM keys, so either side being Solana returns 400 unsupported_address_share.
  • One wallet per address per chain. Repeating a pair returns 409 address_exists.
  • It is deliberately explicit. A customer with several EVM wallets has several addresses, so we will not guess which one you meant to extend.

Reading balances

curl -s $BASE/api/wallets/wal_0K3y... -H "Api-Key: $KEY"
{
  "id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
  "object": "wallet",
  "chain": "solana",
  "address": "3Nq8vT7YbCk1RjvFhcHc3mYwBSy8PmZ6LcQ5tXhr9Aur",
  "status": "active",
  "custody": "non_custodial",
  "signing_reference": "wallet/wal_0K3y2GhUPnpX0X3kBvTwRy/solana",
  "balances": [
    {"object": "balance", "asset": "usdc", "chain": "solana", "amount": "1250.00"},
    {"object": "balance", "asset": "usdt", "chain": "solana", "amount": "0"}
  ]
}

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

Balances ride single-GET responses only. GET /api/wallets and GET /api/customers/{id}/wallets return the same wallet objects without a balances key, so list pages stay cheap. Fetch a wallet by id when you need its balances, or use GET /api/wallets/total_balances for a roll-up across every wallet you hold.

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",
  "amount": "500.00",
  "source": "9Wc4Kx1s5tKUxCnRr8LmVh7pQnWq2ZbEeT4gY6dFhJmN",
  "destination": "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/{id}/walletsOne customer's wallets
GET /api/walletsEvery wallet you hold, filterable by customer_id and partner_reference_id
GET /api/wallets/{id}One wallet, with balances
GET /api/wallets/total_balancesRoll-up across all wallets
GET /api/wallets/{id}/historyActivity log, cursor paginated

Statuses

StatusMeaning
activeLive: receives and sends
frozenBlocked from sending, still receives
closedPermanently closed

A send against a non-active wallet returns 400 wallet_not_active.

Events

EventFires when
wallet.createdA wallet was created
wallet.deposit_receivedFunds arrived at the address, including sends you did not initiate
wallet.withdrawal_createdFunds left the wallet
wallet.deposit_returnedA deposit was returned

wallet.deposit_received 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.