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. Result: a fill signed by the user’s wallet, polled to a terminal state.
Where this fits: Choose your integration. Kalshi uses the user’s own key from Venue keys. What to sign is on Self-custody signing. In a managed trade AGG signs the venue order and funds it from the user’s managed balance. In a self-custody trade the user’s own wallet is both the funding source and the signer. AGG prices the route against that wallet’s on-chain balances, then asks it to sign each step. AGG never holds the key. It uses the same endpoints as a managed trade: GET /orderbook/{venueMarketOutcomeId}/route, then POST /execution/fill. signingAddress is the only opt-in. There is no app flag or header.

The flow

1

Quote with signingAddress

Prices the route against that wallet’s balances. Read custodyWarnings before the user commits: the quote still succeeds, but the fill will not.
2

Fill with the same address

Post the quoteId and the same signingAddress. When the trade is ready to sign straight away, the response already carries the first pendingSignatures[]: sign those immediately, without polling first. Trades that need funding first return none.
3

Sign what the trade asks for

Sign each pendingSignatures payload and submit them to POST /execution/fill/{quoteId}/signatures. Then poll GET /execution/status?quoteId= for any further requests. Repeat until terminal is true. An empty list means “keep polling”, not “done”.
fillSelfCustody in @agg-build/sdk does steps 2 and 3 for you. Pass a signer to createAggClient, and it posts the fill, polls, calls your signer for each request, and submits the results. It returns once the fill is filled or partially_filled, and throws if the fill ends failed, cancelled, or expired, if a round of signing did not move it forward, or after five minutes. You can also drive the loop yourself. The user has to stay present: every request expires, and an expired request cannot be revived. The trade has to be quoted again from the start. See Timeouts. How to write the signer, what each request type asks for, and how many prompts to expect are in the Self-custody signing reference.

The signing wallet

signingAddress must match a wallet already linked to the signed-in account through POST /users/me/link-account/start and POST /users/me/link-account/confirm. Sign the message from the start response verbatim; the confirm step compares it byte for byte. A Solana wallet cannot sign: the venues self-custody supports take an EVM signature. It can fund a trade, though. See Solana. Two refusals appear here, both 400 with a message and no code:

Funding from more than one wallet

By default a trade is funded from signingAddress alone. fundingAddresses adds other wallets the same user has linked, so one trade can draw on several balances. The signing wallet still signs the venue order. Each funding wallet signs only the transfers that move its own money, so signerAddress on a request varies within one fill. Route each request to the wallet it names.
  • Optional. Omitting it keeps the default.
  • Repeat the key on the quote query (?fundingAddresses=0x…&fundingAddresses=0x…); send an array in the fill body.
  • Every entry must be linked to the same account. An entry is an EVM address (0x + 40 hex) or a Solana public key (base58); see Solana.
  • Duplicates and signingAddress itself are removed, and order does not matter: AGG decides which wallet pays what. The quote echoes the final set back as fundingAddresses.
  • On the venue’s own chain only the signing wallet’s balance is usable. A funding wallet’s Hyperliquid balance cannot pay for a Hyperliquid trade, but it can move to other venues.
  • Self-custody limit buys accept the same field, checked when the order is placed.
If you send fundingAddresses on the fill it must match the quote’s set, or the fill is refused with quote_unfillable. Leaving it off the fill skips that check.

Quote and fill

getSmartRoute() does not send signingAddress or fundingAddresses, so use client.request for the quote. Configure your authenticated client with the signer from the signing reference, then call fillSelfCustody to execute and answer the signing requests. The cURL flow requires the manual signing loop after the fill. signingAddress on the fill must match the quoted wallet. Paper mode does not support it. Referral fields are on Fees. Omit approveMode, or send approveMode: "sponsored", unless you need the other approval.
Get a quote and Execute quote. custodyWarnings is absent when there is nothing to report, and is only built on buy quotes. Never read its absence as “this will fill”. A sell uses sellShares instead of maxSpend, not both.

Solana

A Solana wallet linked to the same account can fund a trade on an EVM venue. The EVM wallet still signs the order; the Solana wallet signs one transaction per bridge that moves its USDC across the Relay bridge. The wallet needs no SOL: AGG’s fee payer co-signs and pays the network fee, and the server broadcasts.
1

Link the Solana wallet

Same flow as an EVM wallet, with provider: "siws" (the app must have SIWS enabled as a sign-in provider). POST /users/me/link-account/start with { provider: "siws", address } returns a wallet_challenge; have the wallet sign the message verbatim (signMessage over its UTF-8 bytes, signature base58-encoded) and post { kind: "wallet_signature", message, signature } to POST /users/me/link-account/confirm. client.linkAccount / client.linkAccountConfirm in @agg-build/sdk wrap both calls.
2

Name it in fundingAddresses

Quote with signingAddress (the EVM wallet) and fundingAddresses=<base58 pubkey>; send the same array on the fill. The quote reads the wallet’s Solana USDC balance and plans the bridge from there. A base58 signingAddress is rejected by the schema.
3

Handle solana_transaction in your signer

One request per bridge, addressed to the Solana wallet (signerAddress is base58).
payload carries:
Return 0x + 128 hex chars: the 64-byte ed25519 signature over the decoded message. With a wallet adapter:
Do not send it: the server co-signs as fee payer and broadcasts. The signature must be over the unmodified message. The server verifies it against the bytes it persisted, so a wallet that inserts instructions before signing (priority fee, guards, Lighthouse) gets a 400. Disable such features for this request or sign the raw message. Blockhashes expire. A solana_transaction request has a short deadline, and if the wallet takes too long the fill ends failed with solana_blockhash_expired and has to be quoted again. Fees. When the partner charges a fee and has configured a Solana fee recipient, the fee is collected as USDC transfers appended to the same signed transaction: one prompt, atomic with the deposit. This fee collection is off by default. Until it is enabled, a fee-bearing buy funded from Solana is refused with quote_self_custody_app_fee and reason non_evm_source (reason disabled when the deployment has no self-custody fee collection at all), and a fee-free buy carries only the bridge deposit. A user-paid referral is refused on a Solana-funded buy too (quote_self_custody_app_fee, reason non_evm_source). Solana venues. Clearinghouse orders on Novig and ProphetX, where the order itself is a Solana transaction, are not yet enabled for self-custody. A self-custody route with a clearinghouse leg on either is refused with quote_self_custody_unsupported_venue. The direct backend works as described in the venue table below.

Fees

App and referral fees work as on a managed trade (Fees), but they are collected differently: The Polymarket buy pre-sign is optional: its request carries purpose: "app_fee" and optional: true, and the order never waits for it. The SDK submits the order signatures first, then allows up to 55 seconds for the fee signature. Declining it, or not answering, keeps the order; about a minute after the fill the request expires and one exact fee signature is requested after the fill instead. Sell fee pre-signs remain required. On a HyperCore-funded Hyperliquid trade, the fee asks for up to three extra signatures (your fee, the platform fee, and a user-paid referral), parked next to the order signature. Only non-zero legs are asked for, and the trade is not submitted until all are returned. Self-custody sell fees are available on Polymarket and Hyperliquid only, and only when the platform has enabled them. Otherwise the sell is quoted fee-free with a self_custody_app_fee warning (in warnings[], not custodyWarnings) and is never refused. Polymarket proxy wallets skip the pre-sign and use only the exact post-fill signature. An after-fill fee request arrives once the order already reads filled. Until it is answered, the fill stays at overallState: "confirming" with terminal: false, so keep polling and prompting until terminal is true. The request expires after 30 minutes. Declining it never changes the trade, but the fee is not collected. A fee-bearing self-custody buy is refused when its fee cannot be collected, with quote_self_custody_app_fee and a reason in the message (see Not supported). A fee of 0 bips is always fine. A batch that would only collect a very small fee is skipped, and the trade still fills. For an app-paid referral, your backend answers one more request after the fill. Its signerAddress is your payer wallet, not the trader’s. A client that prompts the connected wallet for every pending request must skip requests whose signerAddress is not its own.

Venues

What the wallet is asked for, in the order the requests arrive, after any funding prompts. “Once” means once per wallet and exchange contract: AGG checks on-chain state first, so an approval already granted is not asked for again. The venue check runs at fill time, not quote time. Venue approvals are transaction requests the wallet sends and pays gas for (Limitless on Base, Predict and Myriad on BNB). The order signature is not asked for until the approve has mined. An approve that is never sent expires after 15 minutes and ends the fill with no order placed; funds already bridged stay in the wallet, and a partner fee already collected is not refunded. Predict funds from the wallet’s own USDT on BNB and Myriad from USD1, or bridges in. USD1 is priced only when Myriad is the only venue the quote can land on. Limitless only lets a partner app trade for Limitless accounts that app created. A wallet that already has its own Limitless account (made on limitless.exchange or through another app) cannot be traded through AGG, and neither can one whose account is set to smart-wallet trading, which rejects orders the wallet signs itself. A self-custody quote for such a wallet leaves Limitless out of the route and carries a self_custody_limitless_foreign_profile or self_custody_limitless_smart_wallet warning. A wallet with no Limitless account yet is registered on its first Limitless trade. On some older negRisk Limitless markets the venue can reject the self-custody order (a buy with Invalid signature, a sell with Conditional token allowance not set); the fill fails with nothing placed. A buy can be re-quoted without signingAddress; such a position cannot currently be sold through AGG. A wallet that owns more than one TipRun account cannot be traded: there is no single account to use without guessing. A self-custody quote for such a wallet leaves TipRun out of the route and carries a self_custody_tiprun_ambiguous_account warning, and a fill is refused with quote_self_custody_tiprun_ambiguous_account. Myriad’s order book silently drops orders from an EIP-7702-delegated trader. A self-custody Myriad fill therefore checks the wallet on BNB right before the order: when it is delegated — by an earlier default-mode approval, or by this fill’s own funding batch, such as the USDT → USD1 conversion on BNB — the user is asked for one more eip7702_authorization with contractAddress set to the zero address, which revokes the delegation. AGG broadcasts that transaction and waits until the wallet reads as a plain EOA before posting the order. After a revoke, the rest of the fill pauses briefly, usually under ten seconds and never more than about thirty, until the revoke is visible across the network, so Myriad does not still see the wallet as delegated. A plain wallet sees no extra prompt and no wait. approveMode works on Myriad as on any other venue. Funding is read from the user’s linked wallet across the EVM chains AGG routes on, their Hyperliquid balance, their Polymarket deposit wallet on Polygon, and a legacy Polymarket proxy wallet where one exists. A linked Solana wallet’s USDC is read only when it is named in fundingAddresses; see Solana. A Solana-only account with no EVM wallet cannot sign and reads as unlinked.

Funding sources

A Hyperliquid buy can be paid from pUSD in the user’s Polymarket deposit wallet. AGG bridges it from Polygon to the EOA’s HyperCore balance before the order:
  • When the deposit wallet is the only source for that amount, it is one eip712 signature from the EOA. The pUSD is unwrapped and bridged from the deposit wallet in a single transaction, and nothing passes through the EOA.
  • Otherwise, for example when the EOA’s own funds pay part of it, the EOA first signs one eip712 transfer that moves the pUSD from the deposit wallet to the EOA. The bridge then asks for the usual cross-chain signatures for that token.
The bridge provider’s fee and Hyperliquid’s first-deposit activation apply as on any bridge into HyperCore. A legacy proxy wallet cannot fund a Hyperliquid buy. A legacy Safe can; see Accounts and wallets.

Choosing an approve mode

Cross-chain funding needs a token approval. There are two ways to give it. It cannot change once the fill is running.
  • Omit the field, or send approveMode: "sponsored". The user does not send an approve transaction. The wallet signs an eip7702_authorization: a signature over a raw digest, neither EIP-191 nor EIP-712. Many wallets cannot produce it. Where it works, the user signs once per chain, and later bridges on that chain need no further approve signature.
  • approveMode: "user_broadcast": the user sends an ordinary approve as type: "transaction" and pays that transaction’s network fee. It sets up no delegation. A token that needs an approval needs it once per token per chain, after which trades use a permit signature only.
Under user_broadcast, a first trade from Polygon USDT or USDC.e is one approve transaction plus one permit signature. Later trades are a permit signature only. Native USDC is one signature. Pick approveMode: "user_broadcast" when the wallet cannot sign a raw-digest authorization. A permit, when the route uses one, stays eip712. That is not the EIP-7702 step. The same split is on Self-custody signing. Neither mode makes bridging free. The bridge provider’s fee is taken from the amount delivered on every bridge, and one-time costs such as Hyperliquid’s first-deposit activation apply per route. Quote with deepEstimate=true and read feeBreakdown.bridgeFees and feeBreakdown.setupCosts. See Deep cost estimate. Four refusals here are terminal and show as errorReason on the status endpoint, not as a 400 on the fill: a wallet already delegated to a different contract on the source chain, a approveMode: "user_broadcast" wallet with no native fee to send the approve; a same-chain conversion under approveMode: "user_broadcast", which needs approveMode: "sponsored" (or the field omitted); and relay_self_custody_app_fee_not_priced: under approveMode: "user_broadcast" the bridge provider did not price the partner fee into the route. It ends the fill before any wallet prompt, so nothing is signed or sent. Re-quote, or clear the fee.

Not supported, with the exact code

Every refusal below is 400 on POST /execution/fill with { message, code }. Refusals with no code, only a message:
  • signingAddress is not supported in paper mode.
  • The two wallet-linking messages above.
  • approveMode "sponsored" is not valid for a self-custody Myriad fill …: omit the field or pass "user_broadcast". The quote stays fillable.
  • Could not verify the signing wallet on BNB right now. Retry the fill in a moment.: a 503 with { retryable: true }. Not a refusal; retry the same fill.
Code that switches only on code misses these. Always show message too. These paths do not accept a signing wallet:
  • Direct orders (POST /execution/orders) reject signingAddress at validation.
  • Withdrawals always act on the managed wallet. A self-custody user already holds their funds.
Self-custody: redeemSelfCustody — Limitless/Predict/Myriad prompt a transaction (user pays gas), Polymarket prompts one typed-data signature and the user sends no transaction. Self-custody redeem becomes available once AGG enables it on the platform; until then the fill is refused with quote_self_custody_redeem_unsupported. On Polymarket it works only for shares in the user’s Polymarket deposit wallet. Shares on the wallet itself (the EOA), or in a legacy Safe or proxy wallet, fail with self-custody redeem needs the shares in your Polymarket deposit wallet.
It requests a sell quote with no size, which the API sizes to exactly the resolved, redeemable shares (a redeem is never partial and never sells open shares), and throws Nothing to redeem: quote is not redeem-only unless the quote settles entirely by redeeming. Each self-custody leg in GET /execution/positions carries signerAddress, the wallet that signs its redeem. In React, pass computeClosedPositionTotals(group).signerAddressByOutcome to useRedeem().mutateAsync({ venueMarketOutcomeIds, signerAddressByOutcome }): those outcomes are redeemed one wallet prompt at a time with the AggWalletSignerProvider signer, the rest through the managed redeem. Resolved self-custody positions on other venues are not claimable through AGG. The user redeems on the venue.

Self-custody limit orders

Pass the linked signingAddress to POST /execution/limit-orders for Polymarket or Hyperliquid. Use the returned quoteId to poll GET /execution/status, sign each pendingSignatures batch, and submit it to POST /execution/fill/{quoteId}/signatures. An empty batch can mean the next step is still running. Placement succeeds when the order is open with a venueOrderId. A resting order is not terminal: track it with GET /execution/orders?orderId=. Polymarket asks for fresh authentication for each new order. The credentials derived from it are used in memory during submission and are not stored. AGG tracks fills in the background. An empty fill history does not prove an order was cancelled or expired, and an unfilled remainder that the venue cancelled or expired can show as active until a fresh signed read confirms it. Cancel with POST /execution/orders/cancel and { orderId, cancelSignature }. AGG cannot cancel a self-custody order without a new signature from the wallet. GTD expiry is enforced by the venue. AGG does not cancel self-custody GTC orders when a market closes. The prebuilt trading UI cannot yet cancel a Hyperliquid order placed from the user’s own wallet: its Cancel button is disabled. Sign and submit the cancel yourself as described in Limit-order cancel signatures.
Do not pass signingAddress through useExecuteManaged from @agg-build/hooks. The call creates real orders, but nothing signs them, so they wait until their requests expire. Use fillSelfCustody from the SDK. For React, wrap your app in AggWalletSignerProvider with a signer such as useWagmiSolanaWalletSigner (from @agg-build/hooks/self-custody), set features.tradingCustody (see Prebuilt trading UI), and let PlaceOrder sign through useSelfCustodySigning.

Prebuilt trading UI

features.tradingCustody on the AggProvider config chooses, for the whole app, which balances users trade from in the @agg-build/ui components:
Any other value is treated as "managed". "both" and "self-custody" need an AggWalletSignerProvider above the trading UI. Paper apps always trade from the paper balance. In "self-custody" mode, the “Trade from” selector lists only wallets. With one linked EVM wallet and features.enableMultiWalletAccountLink off, it shows that wallet as a plain label. If the user has no linked EVM wallet, the trade button becomes “Link an Ethereum wallet to trade”. It opens the same wallet-link flow as sign-in, so mount WalletLinkBridge from @agg-build/auth. This prompt does not depend on enableMultiWalletAccountLink, which only controls linking extra wallets and funding a trade from several of them. A user who signed in with an Ethereum wallet already has that wallet linked. Kalshi trades with an API key, not a wallet, so its route is dimmed for a wallet trade.

Self-custody signing reference

Request types, the signer, prompt counts, and the raw signing loop.

Execute quote

Request and response schema for POST /execution/fill.

Submit signatures

The endpoint behind the signing loop.

Account linking

Link the EVM wallet self-custody needs.