Skip to main content
You’ll need: a signed-in user (Authentication & sessions) with an EVM wallet linked to the account, and client code that can ask that wallet to sign. @agg-build/sdk, @agg-build/hooks and @agg-build/ui 4.33.0 or later. Result: funds moved out of the user’s own wallet, signed by that wallet, polled to a final state.
A managed withdrawal moves funds out of the user’s AGG balance (Funding & withdrawals). A self-custody withdrawal moves funds out of a wallet the user linked to the account. AGG never holds that wallet’s key: the user signs every step, and AGG prices the route and submits what was signed. The user never needs a gas token. It uses the same endpoints as a managed withdrawal, plus POST /execution/withdrawals/{withdrawalId}/signatures for the wallet’s signatures. Sending signingAddress is the only opt-in.

Before you start

  • signingAddress must be an EVM wallet already linked to the signed-in account. A Solana wallet cannot be the signer, though a Solana address can be the recipient.
  • The feature can be off on an environment. A Max quote, preview or withdrawal with signingAddress then returns 400 Self-custody withdrawals are not enabled.
  • Send the same signingAddress and the same approveMode (see Approve mode) on the Max quote, the preview and the withdrawal. All three pick sources by the same rules, so they agree only when they are given the same inputs.
  • A user has one withdrawal in progress at a time, managed or self-custody. A second one returns 409 until the first is final.

What counts as the wallet’s balance

The flow

1

Quote Max for the destination

POST /execution/withdrawable/quote with signingAddress. maxDeliverableRaw is the largest amountRaw the withdrawal accepts for that token and destination chain.
2

Preview the exact amount

POST /execution/withdraw/preview with the full withdrawal body. It prices the route from the wallet and returns unviable, with the reason, for anything the withdrawal would refuse. Optional, but it is the only call that checks recipient rules before you submit.
3

Withdraw

POST /execution/withdraw with the same body. The response may already carry the first pendingSignatures[].
4

Sign what the withdrawal asks for

Sign each request with the wallet and submit the results to POST /execution/withdrawals/{withdrawalId}/signatures. Keep polling GET /execution/withdrawals/{withdrawalId}: it returns any further pendingSignatures[], and later steps can ask for more after earlier ones finish.
5

Poll to a final state

Stop when status is completed, partial or failed.

1. Quote Max

  • maxDeliverableRaw: the largest amountRaw a withdrawal from this wallet accepts. It is an amount debited, not an amount delivered (see Amounts). It leaves out balances too small to move and sources the chosen approveMode cannot spend.
  • rawBalanceRaw: everything the wallet, its deposit wallet and its legacy Safe hold in the accepted tokens, before those exclusions. It can be larger than maxDeliverableRaw.
The API reference describes amountRaw and maxDeliverableRaw for a managed withdrawal. With signingAddress, both are amounts debited, as above.
  • Both are in the destination token’s decimals (6, or 18 on BNB Chain).
The quote does not know the recipient, so it cannot apply recipient rules such as sending to the wallet itself. The preview and the withdrawal do. Quote withdrawable amount.

2. Preview

A quoted preview returns receiveAmountRaw (the estimated amount delivered), feeRaw, and quoteExpiresAt (60 seconds out). An unviable one returns pricingStatus: "unviable" and unviableReason; when no combination of sources covers the amount it also sets maxDeliverableRaw. The preview creates nothing. See Errors and refusals for the reasons. Preview a withdrawal.

3. Withdraw

The method is named withdrawManaged for history; with signingAddress it withdraws from the wallet. max: true is refused with signingAddress: send the exact amount, for example the maxDeliverableRaw from step 1. requestId works as on a managed withdrawal. A shortened response:
  • expected is this withdrawal’s quote: outputRaw is the estimated delivery and feeRaw the estimated fee taken from the amount. The status endpoint returns the actual fee once the withdrawal is final.
  • pendingSignatures is present only when the wallet already has something to sign. Its absence does not mean “nothing to sign”: poll the status endpoint.
  • legs[].type is transfer for same chain and same token, and bridge for another chain or another token on the same chain.
Withdraw funds.

4. Sign and poll

Each request has the same shape as a trade’s, so the signer you wrote for fills works unchanged. See A complete signer.
The signatures endpoint takes the same body as fill signatures (up to 32 entries; signature, or txHash for a transaction) and returns the withdrawal with whatever is still outstanding in pendingSignatures. An unknown withdrawal, or one that is not this user’s, returns 404 Withdrawal not found. Per-step errors (expired, malformed, wrong signer) are the same as for fills: see Driving the loop yourself. Every request expires at its expiresAt. Read it; never hard-code a deadline. Submit signatures for a pending self-custody withdrawal · Get withdrawal status.

Statuses

A withdrawal can draw from several sources, for example the wallet’s Arbitrum USDC and its HyperCore balance. Each source moves on its own signatures. That is why partial exists: one source can deliver while another expires or fails. sources[] and legs[] show each one’s status and txHash.

Amounts

amountRaw is the amount debited from the wallet. The recipient gets amountRaw minus fees. This is the opposite of a managed withdrawal, where amountRaw is the amount to deliver.
  • Integer string in the token’s decimals: 6, or 18 on BNB Chain. 41250000 is 41.25 USDC.
  • The minimum is 1 whole token after any same-chain network fee.
  • Supported tokens are USDC, USDC.e and USDT, on the chains where each exists.

Approve mode

approveMode declares what the wallet can sign. It follows the same rule as fills, with one difference: a withdrawal never asks the wallet to send a transaction or hold gas, in either mode. In React, pick the mode with the same helper as fills:
It returns "sponsored" only when your signer’s canSignEip7702Authorization(address) returns true. useWagmiSolanaWalletSigner from @agg-build/hooks/self-custody does not declare it, so a wallet connected through it gets "user_broadcast". useWithdrawFlow and WithdrawModal do this for you.

Sources and destinations

✅ available, ❌ not available. “Other token” means a different token on the same chain, for example Polygon USDT to Polygon USDC.
  • A HyperCore destination is chain 1337, with an EVM-format address and token USDC.
  • A Solana destination (chain 792703809, base58 address, USDC) is reached by a bridge from any source the mode allows.
  • When a source is not available, Max, preview and the withdrawal use the wallet’s other balances instead. They never pick a source the mode cannot sign.

What the wallet signs

Read type on each request. The signing reference says how to answer each type. Each source adds its own requests. Sources on the same chain run one after another, so the second one’s requests arrive only after the first finishes.

Fees

Every fee comes out of the withdrawn amount. The user never needs a gas token, and your app is never billed for it. Hyperliquid charges the sender 1 USDC the first time anything is sent to an address with no HyperCore account, on top of the amount. The wallet has to hold that extra dollar. A Max withdrawal to a new HyperCore address therefore fails at Hyperliquid and nothing moves. Leave at least 1 USDC behind for a first send. The preview’s feeRaw and the withdrawal’s expected.feeRaw are the estimates; the status endpoint returns the actual fee once the withdrawal is final.

Max and small balances

Max leaves out any source too small to move economically. Today the floors per source are about $0.05 for a same-chain transfer ($0.10 on BNB Chain, $0.20 on Ethereum), $0.25 for a same-chain swap, and $0.50 for a bridge ($3 into Ethereum, $2.50 into Solana). An amount below Max can still be refused when splitting it across sources would leave one part under its floor. The withdrawal returns 400 Insufficient balance for withdrawal. The preview returns unviable with Requested amount exceeds withdrawable balance. Maximum deliverable is … and sets maxDeliverableRaw, even though the amount is under that maximum. Try a slightly different amount, or Max.

Errors and refusals

Returned on the call

These come back from the quote, preview or withdrawal as 400 (or 403, 409) with { message }. The preview returns the reasons marked “also preview” as pricingStatus: "unviable" with unviableReason instead. A withdrawal refused while pricing it (the fee and route rows below) is recorded as failed and moves nothing.

Reported on a failed or partial withdrawal

These come back in errorMessage on the status endpoint after the withdrawal started. Always show errorMessage. A declined prompt cannot be revived: start a new withdrawal.

React

Hooks

useWithdrawFlow drives the whole form (balances, Max, preview, submit). Pass signingAddress to withdraw from that wallet. It then reads Max from the server quote, sends signingAddress and the wallet’s approveMode, and never sends max. useWithdrawalSignatures answers the requests until the withdrawal is final. Both need an AggWalletSignerProvider above them.
withdrawFlow.withdrawable is the wallet’s Max for the chosen token and network, in token units. withdrawFlow.availability reports whether that quote is loading or failed. useWithdrawalSignatures stops on a declined request, which it leaves to expire, and on a 4xx it cannot retry, both shown as error.

Prebuilt modal

WithdrawModal from @agg-build/ui/modals adds a “Withdraw from” selector when all of these hold:
  • features.enableSelfCustodyWithdraw: true (off by default),
  • features.tradingCustody is "both" (see Prebuilt trading UI),
  • an AggWalletSignerProvider with a signer is above it,
  • the user has a linked EVM wallet, and the app is not in paper mode.
useWagmiSolanaWalletSigner reads the wagmi and Solana wallet-adapter contexts, so it must render inside WagmiProvider (with its QueryClientProvider) and the Solana WalletProvider. Any other AggWalletSigner works in its place. The selector lists the AGG balance and each linked EVM wallet. The AGG balance stays selected by default, so the modal behaves as before until the user picks a wallet. The selected wallet’s row shows what it can withdraw to the chosen token and network, the same server Max the Max button fills in. The modal then signs through the provider’s signer and shows each prompt until the withdrawal is final. With the flag off, or any condition missing, the modal is unchanged.

FAQ

Why does Max change when I change the destination?

Max is per token and destination chain. The floors depend on the route (a bridge into Ethereum needs more than a same-chain transfer), a same-chain withdrawal of a different token is a swap with its own floor, and some sources only reach some destinations, for example deposit-wallet pUSD never goes to Polygon USDC.e. Quote again whenever the token, chain or wallet changes.

Why does a MetaMask user see less than a wallet that can sign batches?

MetaMask cannot sign an EIP-7702 authorization, so it withdraws with approveMode: "user_broadcast". That mode never asks for a transaction, so anything that would need a token approval or a batch is left out: the wallet’s USDC.e, USDT and pUSD, and any same-chain swap. Native USDC, HyperCore, the legacy Safe, and (where enabled) the deposit wallet remain. rawBalanceRaw still counts everything, which is why it can be larger than maxDeliverableRaw.

Sending to the signing wallet itself

A same-chain, same-token withdrawal from the wallet’s own balance back to the same wallet is refused with Funds are already in this wallet, and so is HyperCore to HyperCore to itself. Moving funds to the wallet itself from its deposit wallet or legacy Safe is allowed, and so is sending to the wallet’s own address on another chain. Because the Max quote does not know the recipient, this is caught by the preview and the withdrawal, not by the quote.

What happens if the user declines a prompt?

Nothing is retried. The request expires, that source fails, and the withdrawal ends failed or partial. Funds already moved from the deposit wallet into the wallet itself stay in the wallet. Start a new withdrawal to try again.

Can the deposit wallet pay out Polygon USDC.e?

Its USDC.e can, with approveMode: "sponsored" (or omitted). Its pUSD cannot, in either mode: withdraw it to Polygon USDC or USDT, or to another chain, instead. Under "user_broadcast" neither of the deposit wallet’s tokens is a source for Polygon USDC.e, so Max, preview and the withdrawal use the wallet’s other balances.

Self-custody trading

Quote and fill from the user’s own wallet.

Self-custody signing reference

Request types, a complete signer, and the per-step errors.

Funding & withdrawals

Managed withdrawals from the AGG balance.

Submit withdrawal signatures

The endpoint behind the signing loop.