4. Send from a wallet
Register your keys, create a wallet, and take one send through verify, sign, and authorize.
This is the guide that needs real preparation. Wallets are non-custodial: we hold no key that can move the funds, so a send needs your key, live, at the moment of sending.
Prerequisites
- A customer with approved verification and 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 and rotates freely.
- Read Custody Architecture before generating the Recovery Key. It cannot be rotated, and losing it means no new signing key can ever be installed.
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.
Register both public halves
curl -s $BASE/dashboard/partner/wallet_keys \
-H "Content-Type: application/json" \
-d '{"recovery_key": "ed25519:...", "primary_signing_key": "ed25519:..."}' | jq .
We never ask for, accept, or store a private key, and the two must differ.
Registration happens once: re-pointing an identity would leave every
existing wallet derived from the old one, so a second attempt returns
409 keys_already_registered.
The response includes an activation object carrying the envelope for the
next step.
Activate the signing key, once ever
Registering turns wallet creation on. Sending needs one on-chain step that only your Recovery Key can authorize.
Rebuild the activation delegate from your own inputs and confirm that
installing_primary_signing_key is your key and that the digest matches
your rebuild. Then sign the digest raw with the Recovery Key and return
it:
curl -s $BASE/dashboard/partner/wallet_keys/activation/authorize \
-H "Content-Type: application/json" \
-d '{"delegate": "<the base64 you verified>", "signature": "<base64>"}' | jq .
The full procedure is in the Signing Guide. On success, sending is enabled for every wallet you will ever create, and the ceremony never repeats.
Until it completes, wallet creation and deposits work normally, and
creating a send 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",
"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 and involves no third party. The address is derived arithmetic, so there is nothing that could fail or lag.
Fund it
curl -s $BASE/api/sandbox/simulate/wallet_deposit \
-H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"wallet_id": "wal_...", "asset": "usdc", "amount": "500.00"}' | jq .
In production nobody calls an endpoint to fund a wallet: someone sends to
its address, and wallet.deposit_received tells you it landed. 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.00",
"to_address": "5HSqKCN2eoHWe7p36GeUftEL9XnT6MbsvrxHgwgpYWQf"}' | jq .
Nothing moves. 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 clock starts now. You have 30 seconds.
Verify, then sign
Rebuild the transaction from inputs you already trust, and require byte equality before your key touches anything:
- Decode
authorization.solana.message_base64and require it to be exactly oneTransferCheckedfor your amount, to your recipient, on your mint, optionally preceded by one create-account instruction. Not "check the important fields": require the whole message to be one of the two legitimate shapes. - Rebuild the delegate from your identity, your registered signing key,
the
signing_referenceyou stored at creation, and the constants published in the Signing Guide. - Require the canonical hash of your rebuild to equal
signing.digest. - Only then sign that digest, raw, with the Primary Signing Key.
curl -s $BASE/api/wallets/wal_.../operations/wop_.../authorize \
-H "Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"signature": "<base64 raw 64-byte ed25519>"}' | jq .
The full procedure, with the constants to pin and the libraries to use, is the Signing Guide. It is the page to read properly before you write this code.
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_confirmed 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 wait. It moves to expired, and authorizing it
returns 400 operation_expired. Nothing moved and nothing was charged.
Do this once now, deliberately, so you know what it looks like before it happens in production. If it happens often there, the problem is in your pipeline rather than the window: see An operation expired before signing.