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. The response returns before any signing happens, so it never carries pendingSignatures.
3

Sign what the trade asks for

Poll GET /execution/status?quoteId= for pendingSignatures[], sign each payload, and submit them to POST /execution/fill/{quoteId}/signatures. 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. Solana wallets do not qualify. A wallet linked with a Solana signature cannot sign for the venues self-custody supports. 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. Solana entries are refused.
  • 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.

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.

Venue and funding support

The venue check runs at fill time, not quote time. 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 not visible to a self-custody quote.

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, so every bridge needs an approve.
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. Three 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, and a same-chain conversion under approveMode: "user_broadcast", which needs approveMode: "sponsored" (or the field omitted).

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.
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.
Resolved self-custody positions 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.
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.

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.