Withdrawals

Withdrawals are a three-step flow: prepare an unsigned transaction, sign it locally with your key material, then broadcast it.

Send an Idempotency-Key on both calls. If a broadcast fails in a way that leaves the outcome unknown, resend the identical signedRawTx — never a newly signed one. Signing never happens on Guveno: the signingPayload you get from prepare is signed in your environment (usually by the SDK or CLI), then broadcast.

Prepare

POST/withdrawals/prepare

Returns the withdrawal record plus a signingPayload for you to sign offline / client-side.

Choose the source with either walletId or addressId (exactly one):

  • walletId: auto-select. The server picks a source with enough available balance. For account chains (Ethereum, BNB Smart Chain, Polygon, Arbitrum, Solana, TON, XRP, Polkadot, TRON) it picks one address whose balance covers the amount; for Bitcoin and Dogecoin it aggregates UTXOs across the wallet and returns change to a wallet-owned address.
  • addressId: advanced. Send from one specific address. (Bitcoin and Dogecoin can instead pass sourceAddressIds, an array, to restrict aggregation to a chosen subset of the wallet's addresses.)

Because account-based chains can't combine balances in a single transaction, an auto withdrawal fails with 422 if no single address covers the amount.

The asset must be on your organization's asset list (see Assets). Anything else is refused with 403.

Auto-select also requires the wallet's autoWithdrawal setting to be on (the default). If a wallet has autoWithdrawal: false (see POST/PATCH /wallets), a walletId-only prepare is rejected with 400: you must name the source explicitly with addressId (or sourceAddressIds on Bitcoin and Dogecoin) so funds stay segregated per address.

# Auto-select (recommended): let the server pick the source
curl https://api.guveno.com/v1/withdrawals/prepare \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Idempotency-Key: 8f1c-prepare-001" \
  -H "Content-Type: application/json" \
  -d '{ "walletId": 12, "assetId": 3, "amount": "0.25", "toAddress": "0xRecipient..." }'

# Advanced: send from a specific address
curl https://api.guveno.com/v1/withdrawals/prepare \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Idempotency-Key: 8f1c-prepare-002" \
  -H "Content-Type: application/json" \
  -d '{ "addressId": 50, "assetId": 3, "amount": "0.25", "toAddress": "0xRecipient..." }'

XRP destination tags: pass destinationTag, an integer from 0 to 4294967295, when the recipient needs one (exchanges usually do). It is carried into the signed Payment, and a signed transaction whose tag does not match is refused at broadcast. It is accepted only on XRP: sending one on any other chain is a 400, since nothing there could carry it.

curl https://api.guveno.com/v1/withdrawals/prepare \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Idempotency-Key: 8f1c-prepare-003" \
  -H "Content-Type: application/json" \
  -d '{ "walletId": 14, "assetId": 5, "amount": "25", "toAddress": "rRecipient...", "destinationTag": 1433698153 }'

TON memos: pass memo, the text comment the recipient reads, when they need one (exchanges usually do, and match it exactly). It must be 1 to 120 bytes of UTF-8 with no control characters, so no newlines or tabs; a longer memo is refused rather than split. It goes into the signed message (for a jetton, inside the token transfer, where the recipient's notification carries it), and a signed message whose memo does not match byte for byte is refused at broadcast. It is accepted only on TON: sending one on any other chain is a 400, since nothing there could carry it.

curl https://api.guveno.com/v1/withdrawals/prepare \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Idempotency-Key: 8f1c-prepare-004" \
  -H "Content-Type: application/json" \
  -d '{ "walletId": 21, "assetId": 9, "amount": "40", "toAddress": "UQRecipient...", "memo": "4471203" }'

TON addresses: the addresses Guveno gives you are the non-bounceable form, UQ… on mainnet and 0Q… on testnet, because a wallet that has never sent anything is not deployed yet and has to be paid non-bounceable. A destination must be the user-friendly form (EQ…/UQ…, or kQ…/0Q… on testnet) for the network you are sending on. We keep the bounce flag of the address you paste: EQ… asks for the payment to come back if the recipient cannot accept it, UQ… delivers it regardless. The raw 0:… form is refused because it carries no bounce flag, and so is a jetton wallet pasted in place of its owner: send to the recipient's own wallet address and their jetton wallet is worked out from it.

Jetton sends are paid for in GRAM, the TON network's coin (formerly Toncoin). Besides the network fee, each one attaches 0.05 GRAM to the jetton transfer to run the jetton wallets on both sides, and most of that comes back to the source address once the transfer settles. Prepare refuses a jetton send when the source address does not hold enough GRAM for both, so keep some GRAM next to your jettons.

Broadcast

POST/withdrawals

Submit the signed transaction to broadcast it on-chain.

curl https://api.guveno.com/v1/withdrawals \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Idempotency-Key: 8f1c-broadcast-001" \
  -H "Content-Type: application/json" \
  -d '{ "withdrawalId": 7, "signedRawTx": "0x02f8..." }'

Guveno records your signed transaction, and the hash it will have, before handing it to a node, and submits it exactly once. That record is what makes the failure cases below safe.

When a send’s outcome is unknown

A broadcast is the one call in the API whose failure does not mean “nothing happened”. If a node accepted the transaction and the reply was lost, the payment is on the network even though your request failed. Guveno reports that case as 502, with the committed txHash in the body:

{
  "statusCode": 502,
  "message": "The network did not confirm whether this transaction was accepted. ...",
  "txHash": "0x9a1f..."
}

Two safe ways to resolve it, and one dangerous one:

  • Look up the hash on-chain. If the transaction is there, the withdrawal settles by itself and GET /withdrawals/:id will move to broadcast then confirmed.
  • Resend the identical bytes. Call POST /withdrawals again with exactly the same signedRawTx. The same bytes are the same transaction, so this is not a second payment.
  • Never sign a replacement. On an account chain a new signature means a new nonce, so if the first transaction did land, the recipient is paid twice.

The API enforces the last point rather than trusting callers. While a send is unresolved: POST /withdrawals returns 409 for any signedRawTx other than the one it already holds; replaying POST /withdrawals/prepare with that withdrawal's idempotency key returns 409 instead of issuing a fresh signing payload; and preparing any new withdrawal from the same address returns 409 until the outstanding one is reconciled. Contact support if a transaction never arrives.

A 400 from a broadcast is different: it means the node refused the transaction on its merits (most often the source can't fund gas). Nothing was sent, the withdrawal stays prepared, and you can fix the problem and broadcast again.

Idempotency

Both calls require an Idempotency-Key header. Reusing the same key returns the original result instead of creating a duplicate. For prepare that makes a retry safe on any error. For broadcast the key alone is not enough — reuse the key and the same signedRawTx, as above. Both endpoints need withdrawals:create granted on the source wallet, and the member behind the key needs a company role that can withdraw (signer or above).

Signing & API keys

An API key can prepare and broadcast withdrawals, but it cannot sign them. Signing uses the wallet's private key, which lives only with you. Guveno never holds spendable key material: the most a key with a signing action can read is the wallet's secret sealed to your own key (GET /wallets/:id/secret), which only your encryption password opens. So the middle step (turning the signingPayload into signedRawTx) runs in your own environment, typically via the SDK or CLI which hold the mnemonic. A fully unattended signer therefore needs your key material on the machine that calls the API, so plan your custody accordingly.

List

GET/withdrawals
GET/withdrawals/:id

Returns newest-first. Optional filters combine (AND): walletId, addressId, chain, network and status (prepared, broadcast, confirmed or rejected). Set the page size with limit (default 100, max 200) and pass the returned pageInfo.nextCursor back as cursor to fetch the next page. A null cursor means you've reached the end.

sourceAddresses names every address the withdrawal spends from, each once, with the address addressId points to first. Account chains have exactly one; a Bitcoin or Dogecoin withdrawal also lists every address whose coins it spends. The set is fixed at prepare.

curl "https://api.guveno.com/v1/withdrawals?walletId=12&status=broadcast&limit=50" \
  -H "Authorization: Bearer gv_live_your_key_here"
{
  "items": [
    {
      "id": 7,
      "addressId": 50,
      "sourceAddresses": ["0x9999..."],
      "assetId": 3,
      "network": "mainnet",
      "toAddress": "0xRecipient...",
      "amount": "0.25",
      "status": "broadcast",
      "txHash": "0x02f8...",
      "createdAt": "2026-06-14T11:55:00.000Z"
    }
  ],
  "pageInfo": { "nextCursor": "eyJpZCI6N30", "hasNextPage": true }
}

Withdrawal requests

POST/withdrawal-requests
GET/withdrawal-requests
GET/withdrawal-requests/:id

The prepare → sign → broadcast flow assumes the caller can sign. A withdrawal request is for the case where it cannot, and shouldn't: an integration proposes a payout, and a person reviews and signs it. Creating a request moves no funds, signs nothing, and authorizes nothing — it records an intent. A leaked API key gets you a proposal somebody still has to approve. Requests are part of the Pro and Enterprise plans: on other plans POST /withdrawal-requests answers 403, and so does approving one in the dashboard. Reading and declining are never gated, so requests already waiting stay visible after a downgrade.

The key needs withdrawals:request on the wallet — not withdrawals:create, which is the signing action and is what the approver spends. A key holding only withdrawals:create cannot propose, and one holding only withdrawals:request can never send; that split is the point.

Two separations hold, and both are enforced server-side. An API key can never decide: approving and declining require an interactive session. An approver must be able to sign — they need key access on the wallet, checked before anything is reserved. On top of that, a request a person proposed needs a different person to approve it; one an API key proposed may be approved by the key's own member, since that member could withdraw directly anyway and requiring a second human would lock a one-person company out of the flow entirely.

curl -X POST "https://api.guveno.com/v1/withdrawal-requests" \
  -H "Authorization: Bearer gv_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "walletId": 12,
    "assetId": 3,
    "toAddress": "0xRecipient...",
    "amount": "1.5",
    "idempotencyKey": "exchange-withdrawal-991",
    "reference": "exchange tx 991",
    "approverUserId": 5
  }'

Idempotent on idempotencyKey: a repeat with the same key returns the original request, so a caller that already debited a user and then lost the response can retry without proposing a second payout. The same key with different parameters is a 409, never a silent overwrite.

approverUserId asks a specific member to review it — they are emailed and notified, and the request shows as waiting on them. It is advisory: any other authorized member can still approve, so an approver who is away cannot strand a payout. expiresInSeconds runs from 5 minutes to 30 days (7 days by default); an unanswered request expires rather than lingering as a standing authorization to move funds.

On XRP, destinationTag (an integer from 0 to 4294967295) is kept on the request exactly as proposed, and it is what the approver signs. It is refused on any other chain.

On TON, memo (1 to 120 bytes of UTF-8, no control characters) is kept on the request the same way and signed exactly as proposed. It is refused on any other chain.

Approving and declining happen in the dashboard. The approver opens the request, reviews it and signs it in their browser. The destination, amount and asset come from the stored request, so the approver signs exactly what was proposed, and the request becomes approved once that broadcast succeeds. There is deliberately no route an API key alone can reach that moves funds, or that decides a request.

Poll GET /withdrawal-requests/:id for the outcome. approved carries withdrawalId — read that withdrawal for the hash and confirmations. rejected means a person declined it (declineReason says why) and expired means nobody acted in time; neither produces a withdrawal and neither is retried, so propose a new request if you still want the payout. Note that a decline emits no webhook — withdrawal.broadcast fires for an approval because a withdrawal exists, but a declined request never becomes one, so polling is how you learn about it.

Spending policies

An organization on Pro or Enterprise can set spending policies in the dashboard (Security, then Policies): a limit per transaction, a rolling limit per hour, day, week or month, a cap on how many withdrawals happen in a window, a list of approved destinations, a check on the first payment to a new address, or business hours. Each one applies to the whole organization or to chosen wallets, teams, people, roles or API keys, and either blocks the withdrawal, sends it for approval, or lets it through and tells the owners and admins. Policies are created and changed only in the dashboard, and relaxing one waits for a second owner or admin. An API key cannot read or change them.

They do apply to everything an API key does. POST /withdrawals/prepare and POST /withdrawal-requests are checked before anything is reserved, and a refusal is a 403 with a code (see Errors). When the answer is policy_approval_required, propose the same payout as a withdrawal request instead. A request a policy holds says what it is waiting for:

{
  "request": {
    "id": 7,
    "status": "pending_approval",
    "policyRequirements": [
      { "policyId": 2, "policyName": "Large withdrawals", "approvals": 2, "approverRoles": ["owner", "admin"] }
    ],
    "approvals": [{ "userId": 5, "createdAt": "2026-10-05T14:02:11.000Z" }],
    ...
  }
}

policyRequirements is empty when only the usual single approver is needed. approvals lists the people who have signed off so far; the member who finally signs counts as the last approval, and whoever proposed it (including through their own API key) never counts. USD limits use the current price, and when no price is available the withdrawal is treated as over the limit, never under it.

Need a hand integrating? Email support@guveno.com.
API reference v1.10.0 · Last updated October 17, 2026