Quickstart4. Send from a Wallet

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.

Next

Set up webhooks.