Quickstart4. Send from a Wallet

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:

  1. Decode authorization.solana.message_base64 and require it to be exactly one TransferChecked for 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.
  2. Rebuild the delegate from your identity, your registered signing key, the signing_reference you stored at creation, and the constants published in the Signing Guide.
  3. Require the canonical hash of your rebuild to equal signing.digest.
  4. 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.

Next

Set up webhooks.