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.
| Key | Where it belongs | What it does |
|---|---|---|
| Primary Signing Key | Hot signing service (KMS or HSM) | Authorizes day-to-day sends. Addresses never depend on it |
| Recovery Key | Cold storage, offline | Is 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"
}
| Field | Notes |
|---|---|
chain | Required. See Coverage for what is live |
network | mainnet on live, testnet in sandbox. chain reads the same in both |
name | Display label, and not unique. Use partner_reference_id as your handle |
partner_reference_id | Your own id, unique per environment. List endpoints filter on it |
custody | Always non_custodial. Stated on every read because it is a property you integrate against |
signing_reference | The wallet's immutable derivation reference. Store it at creation: your verifier compares against it on every send |
address_wallet_id | Reuses 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"
}
kind | Meaning |
|---|---|
deposit | Funds arrived at the address, including sends we never initiated |
withdrawal | Funds left the wallet through an authorized send |
return | A deposit was sent back |
History is an activity log, not the source of truth for balances. Read balances from the wallet object.
Listing
| Endpoint | Returns |
|---|---|
GET /api/customers/{customer_id}/wallets | One customer's wallets |
GET /api/wallets | Every wallet you hold, filterable by customer_id and partner_reference_id |
GET /api/wallets/{wallet_id} | One wallet, with balances |
GET /api/wallets/total_balances | Roll-up across all wallets |
GET /api/wallets/{wallet_id}/history | Activity 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
| Event | Fires when |
|---|---|
wallet.created | A wallet was created |
wallet_transaction.created | Funds arrived at the address, including sends you did not initiate (kind: "deposit"), or left it (kind: "withdrawal") |
wallet_transaction.returned | A deposit was returned |
wallet_operation.completed | A send you authorized landed |
wallet_operation.failed | A 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.