Prerequisites: Authentication, and an EVM wallet
linked to the signed-in account.
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 fromsigningAddress 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
signingAddressitself normalise away, and array order carries no meaning — AGG decides which wallet pays what. The quote echoes the normalised set back asfundingAddresses; 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.
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
Quote —GET /orderbook/:venueMarketOutcomeId/route
Fill —
POST /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 oneSignatureRequest 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.
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
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 carriesexpiresAt. 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.
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 is400 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.
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 unknownsigningAddressfield at schema validation. - Withdrawals always act on the managed wallet. A self-custody user already holds their own funds and exits through the venue.
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.
Related
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.