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 there is no endpoint that sends money. There is one that prepares a send and waits for you.
A send is an operation: something we build, you approve, and we then execute.
Prerequisites
- Both public keys registered, and the Primary Signing Key activated,
otherwise
400 activation_required. - The wallet is
active, otherwise400 wallet_not_active. - The wallet holds the amount, checked against the chain, 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.00",
"to_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.
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. This is the step that makes the whole design work: 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 "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_confirmed fires with the tx_hash, or
wallet.operation_failed if it did not land. Poll
GET /api/wallets/{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: a finalized blockhash is already about 35 slots old when we get it, leaving roughly 46 seconds of its 150-slot life, and the message cannot be rebuilt once your key has signed it. So the window has to close well before the blockhash does.
Miss it and the operation goes expired. Nothing moved, and you 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 walks each link:
intent -> the destination transaction (decode the message or the calldata)
-> the payload hash (recompute it from that transaction)
-> the sender (re-derive it from your identity)
-> the contracts (pinned by you, not taken from us)
-> the token and its decimals (from what the intent implies)
-> the signing request (carries exactly that payload, nothing more)
If any link fails, the client refuses and you never produce a signature.
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.
The check happens before any signature exists. It never relies on the chain rejecting a bad transaction afterwards, because by then the signature is already out.
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",
"network": "solana",
"asset": "usdc",
"amount": "250.00",
"destination": "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": {
"operation": "send",
"network": "solana",
"asset": "usdc",
"amount": "250.00",
"destination": "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 delegate 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.
When EVM chains enable, a wallet's first EVM send authorizes two payloads instead of one: the send, plus the one-time on-chain setup that lets the wallet transact without holding native tokens. The count and the order are part of what the digest commits to. Solana sends are always exactly one.
Statuses
authorization_required, authorized, signing, signed, submitting,
submitted, confirmed, with failed, expired, and returned as
terminal exits. The full table is in
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.
Errors
| Code | When | Class |
|---|---|---|
activation_required | Your Primary Signing Key is not activated yet | actionable |
wallet_not_active | The wallet is frozen or closed | actionable |
invalid_parameter | Unknown asset, or an asset with no contract on the wallet's chain | actionable |
invalid_amount | The amount is finer than the asset's decimals | actionable |
insufficient_balance | The chain balance is short at build time | actionable |
operation_expired | The 30-second window elapsed. Create a new operation | actionable |
invalid_signature | The signature is not your registered key's over the digest | actionable |
operation_not_authorizable | The operation already reached a terminal state | terminal |
operation_not_found | No such operation on that wallet | terminal |
Authorizing an operation that is already executing is an idempotent replay, 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.