Core ConceptsSending Funds

Sending Funds

Every send is an operation you verify and authorize with your own key. We prepare it, you approve it, then we execute.

Why sending looks different here

Wallets are non-custodial: we hold no key that can move your customers' funds. So no endpoint sends money. Instead, a send is an operation: we build it, you verify and sign it, and we then execute it.

Prerequisites

  • Both public keys registered, and the Primary Signing Key activated, otherwise 400 activation_required.
  • The wallet is active, otherwise 400 wallet_not_active.
  • The wallet holds the amount, checked against the chain less what other operations already commit (executing, or awaiting authorization and not yet expired), otherwise 400 insufficient_balance. Funds that arrived by direct send are immediately spendable.
  • A signing service that can verify and sign in well under a second. The window is 30 seconds end to end.

Create the 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"
  }'

Nothing moves. We read the chain, build the exact transaction, and return an operation in status authorization_required carrying an authorization object and a signing envelope.

The chain is the wallet's, never a parameter: a mismatch between them could only ever be a mistake. The request is refused before anything is built when:

  • destination_address is not a valid address on the wallet's chain: 422 invalid_parameter with param: destination_address.
  • asset is unknown or not carried on the wallet's chain: 422 invalid_parameter with param: asset.
  • amount is finer than the asset's decimals (6 for USDC): 400 invalid_amount.

GET /api/wallets/{wallet_id}/operations lists a wallet's operations.

Verify the authorization

Rebuild and compare before you sign. The complete procedure, the constants to pin, and the libraries to use are in the Signing Guide. For why this step exists, see Never sign blind.

Sign and authorize

Your verifier hands the Primary Signing Key the digest it has just confirmed. Return the result:

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>"}'

We verify the signature against your registered key at the door (422 invalid_signature otherwise), the operation moves to authorized, and execution begins.

Watch for the outcome

wallet_operation.completed fires with the tx_hash, or wallet_operation.failed if it did not land. Poll GET /api/wallets/{wallet_id}/operations/{operation_id} if you prefer.

Chain chores are ours, not flags you pass. On Solana we open the recipient's token account if it does not exist and cover the network fee. You are billed for gas at cost plus your plan's markup. See Fees & Quotes.

Operations expire in 30 seconds

An authorization pins live chain state. On Solana that is a blockhash with a hard on-chain lifetime of 150 slots, roughly 60 seconds from the confirmed blockhash we build on, and the message cannot be rebuilt once your key has signed it. So the window has to close well before the blockhash does.

Authorizing after expires_at returns 400 operation_expired and moves the operation to expired. Nothing moved, so create a new one.

Do not build a flow that waits for a human to click approve inside that window. Have your signing service verify and sign automatically, and put any human approval before you create the operation.

Never sign blind

The threat this design closes is blind signing: being handed an opaque hash that turns out to move your customer's funds somewhere you never agreed to.

So we never hand you a bare hash. The authorization object carries the human-readable intent and every input needed to rebuild the transaction independently. Your verifier rebuilds the transaction and the signed structure from values you already trust, requires them to match ours byte for byte, and refuses to sign on any difference. The Signing Guide is the procedure.

Run the verifier. Do not skip it because the request came from us. The verifier exists to be correct when we are compromised. An authorization that is internally consistent can still be a lie about the world. Signing an unverified authorization gives up the guarantee non-custodial wallets exist to provide.

What the object looks like

The operation carries two siblings: the authorization (what to verify) and the signing envelope (what to sign, once verification passes). Both appear only while the status is authorization_required, so status polls stay small.

{
  "id": "wop_033y8OpQrStUvWxYzAbCd",
  "object": "wallet_operation",
  "wallet_id": "wal_0K3y2GhUPnpX0X3kBvTwRy",
  "customer_id": "cus_033y1FhTOmoW9W2jAuSvQx",
  "kind": "send",
  "status": "authorization_required",
  "chain": "solana",
  "network": "mainnet",
  "asset": "usdc",
  "amount": "250",
  "destination_address": "5HSqKCN2eoHWe7p36GeUftEL9XnT6MbsvrxHgwgpYWQf",
  "tx_hash": null,
  "expires_at": "2026-08-26T09:50:30+00:00",
  "created_at": "2026-08-26T09:50:00+00:00",
  "authorization": {
    "version": 1,
    "operation_id": "wop_033y8OpQrStUvWxYzAbCd",
    "identity": {
      "signing_identity": "9f1c...",
      "signing_reference": "wallet/wal_0K3y2GhUPnpX0X3kBvTwRy/solana",
      "wallet_id": "wal_0K3y2GhUPnpX0X3kBvTwRy"
    },
    "intent": {
      "kind": "send",
      "chain": "solana",
      "asset": "usdc",
      "amount": "250",
      "destination_address": "5HSqKCN2eoHWe7p36GeUftEL9XnT6MbsvrxHgwgpYWQf"
    },
    "solana": {
      "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      "decimals": 6,
      "fee_payer": "FeePayer1111111111111111111111111111111111",
      "message_base64": "<the exact transaction message>"
    },
    "anti_replay": {"nonce": "wop_033y8OpQrStUvWxYzAbCd", "expiry_unix": 1787477430},
    "commitment": {"destination_payload": "0x...", "scheme": "ed25519"}
  },
  "signing": {
    "version": 1,
    "algorithm": "ed25519",
    "delegate": "<base64: the exact bytes to be relayed>",
    "digest": "<hex: the 32 bytes your key signs>",
    "nonce": 265547925000001,
    "nonce_index": 0,
    "max_block_height": 265555262
  }
}

intent is what you meant. Everything else is what it takes to prove the transaction matches it. Three fields worth understanding:

  • commitment.destination_payload: the destination-chain bytes that will execute. Your verifier recomputes this from the decoded transaction rather than trusting it.
  • signing.digest: the only thing your Primary Signing Key ever signs, and only after your own rebuild of the signing payload hashes to exactly it.
  • signing.nonce, nonce_index, max_block_height: scheduling, ours to choose. They bound when the authorization can land. They cannot change what it authorizes.

Statuses and errors

For every status and the webhook each one fires, see Statuses & Lifecycles. The distinction that matters operationally: failed means nothing moved and creating a replacement is safe. returned means the funds moved and came back, so reconcile before re-sending.

For every error code and its retry class, see Errors. Authorizing an operation that is already executing is not an error: you get the operation back unchanged.

Limits at launch

  • Transfers only. Sends move stablecoins. Arbitrary contract interaction is not enabled.
  • Solana DeFi is disabled. The signature for a Solana transaction is publicly visible shortly before it reaches the chain. That is harmless for a transfer, but for a swap it is a front-running vector, so it stays off until it can be closed properly.
  • No signing-only endpoint. There is no way to have a wallet's key applied to a transaction you built yourself: the key exists nowhere we could apply it.