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.
| Key | Where it belongs | What it does |
|---|---|---|
| Primary Signing Key | Hot signing service (KMS or HSM) | Authorizes day-to-day sends. Rotate it freely: addresses never change |
| 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 |
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"
}
| Field | Notes |
|---|---|
chain | Required. See Coverage for what is live |
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 | Reuse 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"
}
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/{id}/wallets | One customer's wallets |
GET /api/wallets | Every wallet you hold, filterable by customer_id and partner_reference_id |
GET /api/wallets/{id} | One wallet, with balances |
GET /api/wallets/total_balances | Roll-up across all wallets |
GET /api/wallets/{id}/history | Activity log, cursor paginated |
Statuses
| Status | Meaning |
|---|---|
active | Live: receives and sends |
frozen | Blocked from sending, still receives |
closed | Permanently closed |
A send against a non-active wallet returns 400 wallet_not_active.
Events
| Event | Fires when |
|---|---|
wallet.created | A wallet was created |
wallet.deposit_received | Funds arrived at the address, including sends you did not initiate |
wallet.withdrawal_created | Funds left the wallet |
wallet.deposit_returned | A 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.