4. Send from a wallet
Start with registered keys, create a wallet, and take one send through verify, sign, and authorize.
This guide needs preparation before you start. Wallets are non-custodial: we hold no key that can move the funds, so every send needs a signature from your key at the moment of sending.
Prerequisites
- A customer with at least one approved endorsement. See Onboard a customer.
- Two ed25519 keypairs of your own, and something that can sign a raw
32-byte digest with them:
- a Recovery Key, cold. It is your permanent identity, and every wallet address derives from it.
- a Primary Signing Key, hot. It authorizes sends.
- Read Custody Architecture before generating the Recovery Key. It cannot be rotated, and losing it means no new signing key can ever be installed.
- Your two public keys registered and your Primary Signing Key activated, on the dashboard's Wallets page. Registration is per environment: sandbox and live each need their own pair, and a key already registered in one environment is refused in the other.
Use throwaway sandbox keys for this walkthrough. Generate your real Recovery Key offline, in the KMS or HSM it will live in, and back it up like a root of trust.
Confirm wallet key setup
On the dashboard's Wallets page, in sandbox, paste the public halves of your Recovery Key and Primary Signing Key. Then prepare the activation, verify it, sign its digest with the Recovery Key, and submit the signature. We never ask for, accept, or store either private key.
The full verification procedure is in the
Signing Guide.
Do not continue until the Wallets page shows sending enabled. Creating a
send before activation completes returns 400 activation_required.
Create a wallet
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"}' | jq .
{
"id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
"object": "wallet",
"customer_id": "cus_033y1FhTOmoW9W2jAuSvQx",
"chain": "solana",
"network": "testnet",
"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"
}
Store signing_reference now. It is immutable, and your verifier
compares against it on every send. Storing it at creation is what makes the
comparison meaningful: a value you read back later came from the same
platform you are verifying.
Creation is instant. The address is derived arithmetic, with no third party involved.
Fund it
Sandbox wallets live on Solana devnet, so you fund one the way you fund it
in live: send tokens to its address. Send devnet USDC from a devnet
faucet or any devnet wallet. The sandbox mint is in the
Signing Guide.
When the deposit lands, wallet_transaction.created fires with
kind: "deposit". Read the balance
back, which is a chain read, not a ledger:
curl -s $BASE/api/wallets/wal_... -H "Api-Key: $KEY" | jq '.balances'
Create an operation
curl -s $BASE/api/wallets/wal_.../operations \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"asset": "usdc", "amount": "250",
"destination_address": "5HSqKCN2eoHWe7p36GeUftEL9XnT6MbsvrxHgwgpYWQf"}' | jq .
Nothing moves yet. The response is an operation in status
authorization_required carrying two siblings: an authorization object
(what to verify) and a signing envelope (what to sign, once verification
passes). The chain is the wallet's, never a parameter.
The operation expires 30 seconds after creation, at its expires_at.
Verify, then sign
Rebuild the transaction and the signing payload from inputs you already trust,
require your rebuild to hash to signing.digest, and only then sign that
digest, raw, with the Primary Signing Key. The procedure, the constants and
mints to pin, and the libraries to use are in the
Signing Guide. Read it before you
write this code.
curl -s $BASE/api/wallets/wal_.../operations/wop_.../authorize \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"signature": "<base64 raw 64-byte ed25519>"}' | jq .
Do not build a flow that waits for a human inside the 30-second window. Verify and sign automatically, and put any human approval before the operation is created.
Watch for the outcome
wallet_operation.completed fires with the tx_hash, or
wallet_operation.failed if it did not land. Or poll:
curl -s $BASE/api/wallets/wal_.../operations/wop_... -H "Api-Key: $KEY" | jq '.status'
Let one expire on purpose
Create an operation and let expires_at pass. Authorizing it then
returns 400 operation_expired, and the operation reads expired. Until
you call authorize, a GET still shows authorization_required, so judge
expiry by expires_at. Nothing moved and nothing was charged.
Do this once now, so you know what it looks like before it happens in live. If it happens often there, the problem is in your pipeline rather than the window: see An operation expired before signing.