Skip to main content
Prerequisites: Authentication, and an EVM wallet linked to the signed-in account.
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 parks a series of payloads for it to sign. AGG never holds the key. It runs on the same two endpoints as a managed trade — GET /orderbook/:venueMarketOutcomeId/route, then POST /execution/fill. signingAddress is the only opt-in: there is no app flag and no 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. The response returns before any signing happens, so it never carries pendingSignatures.
3

Sign what the run parks

Poll GET /execution/status?quoteId=… for pendingSignatures[], sign each payload, and submit to POST /execution/fill/:quoteId/signatures. Repeat until the fill is terminal — 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 advance it, or after five minutes. The poll interval and deadline are fixed. Everything here is plain REST, so you can also drive the loop yourself. Whichever way you drive it, the user has to stay at the keyboard: every request expires, and an expired request cannot be revived — the fill has to be re-quoted from the beginning.

The signing wallet

signingAddress is never trusted on its own. It 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. Solana wallets do not qualify. A wallet linked with a Solana signature cannot sign for the venues self-custody supports, so it is not a candidate signer. Two refusals appear here, both 400 with a message and no code field:

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 at once. The signing wallet still signs the venue order. Each funding wallet signs only the bridge legs that move its own money — so signerAddress on a SignatureRequest varies within a single fill, and your signer has to route each request to the wallet it names rather than to whichever account the wallet has selected.
  • Optional. Omitting it behaves exactly as before.
  • 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, the same rule as signingAddress. Solana entries are refused.
  • Duplicates and signingAddress itself normalise away, and array order carries no meaning — AGG decides which wallet pays what. The quote echoes the normalised set back as fundingAddresses; pin from that rather than from your own input.
  • 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, though the same balance bridges normally to any other venue.
If you send fundingAddresses on the fill it must match the set the quote was priced against, or the fill is refused with quote_unfillable. Leaving it off the fill skips that comparison entirely — that is what keeps callers written before this field working, so send it if you want the check.

Request shape

QuoteGET /orderbook/:venueMarketOutcomeId/route FillPOST /execution/fill
custodyWarnings is absent when there is nothing to report, so absent and empty mean the same thing. It is only assembled on buy quotes — never read its absence as “this will fill”.

Writing the signer

Your signer receives one SignatureRequest at a time and returns either a 0x hex string or an object carrying a broadcast transaction hash.
A fill funded entirely from a legacy Polymarket Safe asks for exactly one safe_tx signature and never an approve or an EIP-7702 authorization — the Safe itself never broadcasts anything. A fill with mixed funding (Safe plus your EOA or deposit wallet on the same lane) still asks for the usual EOA-side steps in addition to that safe_tx.
Throwing from your signer is not a rejection. fillSelfCustody rejects at once and drops any signatures it had already collected for that batch, but the server has no reject verb: the parked request simply expires and the fill dies.If you know a wallet cannot produce a raw-digest EIP-7702 authorization, declare approveMode: "user_broadcast" when you call the fill. It cannot be changed once the fill is in flight.
transaction appears in exactly one situation: the fill has to bridge, the Relay quote for the source token opens with an on-chain ERC-20 approve rather than a permit signature, and you set approveMode: "user_broadcast" on the fill. In the default sponsored mode that same approve is signed as eip712, so transaction never appears. Venue orders, the Polymarket wrap, and the Hyperliquid builder-fee approval are all signatures, never transactions. When it does arrive, return the hash without awaiting the receipt. AGG watches for the mined receipt itself and will not resume until it matches the request. solana_transaction exists in the type union but is reserved — it cannot be submitted. Give your signer a default branch that throws rather than silently returning.

How many wallet prompts the user sees

Requests parked together arrive in one batch, but the user still approves each one. The count is the sum of three independent parts: the venue’s order cost, a Polymarket funding step, and the funding lane. Venue order Polymarket funding step
This one is not a first-time cost. Funding lands as USDC.e, which has to be wrapped before a buy can settle, so a returning user pays it on every freshly funded buy. Budget for it in your UX rather than promising a cheaper second trade.
Funding lane, per lane — a route funded from two chains pays this twice. Worked examples: a Hyperliquid buy from an existing Hyperliquid balance is 1 (2 if the builder approval is still outstanding). A Polymarket buy bridged from native USDC is 4 — one lane, one wrap, two order signatures — and stays 4 on repeat. The same buy from a token needing an approval, on a wallet not yet delegated, is 6. A Polymarket sell is 2, or 3 the first time that deposit wallet trades.

Timeouts, and what expiry means

Every request carries expiresAt. Read it; never hard-code a deadline. Different steps in the same fill can have different deadlines, and the shortest is tight: When a fill dies, GET /execution/status reports an overallState of failed or expired with an errorReason, and fillSelfCustody throws with that state in its message. An expired request disappears from the pending list rather than appearing as expired, so an empty list never means “done” — only a terminal state does. Submitting a signature for an expired request returns 400 with request for step … has expired — re-quote and retry. There is no recovery: quote again and start over. Note that a user has one live execution at a time — if a request expires rather than being answered, the replacement fill will not begin until the abandoned run has finished dying, so prompt the user to complete or abandon deliberately rather than re-quoting on top of a live request.

Venue and funding support

The venue check runs at fill time, not quote time. A quote that routes to an unsupported venue looks fine and then refuses. 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. Solana balances are invisible to a self-custody quote, and a Solana-only wallet reads as insufficient balance.

Choosing an approve mode

Cross-chain funding needs an on-chain approval, and there are two ways to get it.
  • sponsored (default) — the approve costs the wallet no gas, but the wallet must sign an EIP-7702 authorization: a signature over a raw digest, neither EIP-191 nor EIP-712. Many wallets do not expose it. Where it works, the user signs once per chain and later bridges on that chain need no approve and no further signature.
  • user_broadcast — the user broadcasts an ordinary approve from their own wallet and pays its gas. It never establishes the delegation, so an approve is needed on every bridge.
Pick user_broadcast when you know the user’s wallet cannot sign a raw-digest authorization. Neither mode makes bridging free. The approve is only one of the costs: Relay’s fee is deducted from the amount delivered on every bridge, and one-time costs — Hyperliquid’s first-deposit activation among them — apply per route. Quote with deepEstimate: true and read feeBreakdown.bridgeFees and feeBreakdown.setupCosts rather than inferring cost from the approve mode. See Deep cost estimate. Three refusals here are terminal and surface as errorReason on the status endpoint rather than as a 400 on the fill: a wallet already delegated to a different contract on the source chain, a user_broadcast wallet with no native gas to send the approve, and a same-chain conversion under user_broadcast, which needs the sponsored batch.

Not supported, with the exact code

Every refusal below is 400 on POST /execution/fill with { message, code }. Refusals with no code field, only a message:
  • signingAddress is not supported in paper mode. — self-custody is live mode only, on both the quote and the fill.
  • The two wallet-linking messages listed above.
A partner switching only on code will miss these. Always render message too. Three further paths do not accept a signing wallet at all:
  • Limit orders always fund from the managed balance.
  • Direct venue orders (POST /execution/orders) reject an unknown signingAddress field at schema validation.
  • Withdrawals always act on the managed wallet. A self-custody user already holds their own funds and exits through the venue.
Resolved self-custody positions are likewise not claimable through AGG. The user redeems through the venue’s own interface.

Driving the loop yourself

Driving the poll/submit loop yourself means handling these responses.
pendingSignatures is always absent on the fill response itself, including for a self-custody fill — the run parks asynchronously, after the response is sent. Its absence there never means “no signing needed”.
Re-submitting a step that already succeeded is ignored rather than an error, so retrying a batch after a network blip is safe. Do not send the same stepId twice in one body — duplicates are de-duplicated last-wins, so one bad entry discards the good one and the whole request fails.
Do not pass signingAddress through useExecuteManaged from @agg-build/hooks. The call succeeds and creates real orders, but nothing ever signs them — the fill parks until its requests expire. Self-custody requires fillSelfCustody from the SDK.

Fill API

Full request and response schema for POST /execution/fill.

Submit Signatures

The endpoint behind the signing loop.

Compute Order Route

The quote endpoint signingAddress is passed to.

Account Linking

Linking the EVM wallet that self-custody requires.