# Agg.market — Prediction Market Aggregator > White-label prediction market aggregator API: sign users in, quote the best price across venues, execute trades, and track them to a final status. Start with Quickstart: REST. > ## Agent Instructions > To integrate AGG, start with Quickstart: REST (https://docs.agg.market/quickstart/rest), then Order lifecycle & statuses (https://docs.agg.market/concepts/order-lifecycle). > Track every trade until it reaches a terminal status. Before retrying a failed or timed-out call, read Errors, retries & idempotency (https://docs.agg.market/concepts/errors). > The API reference is generated from https://docs.agg.market/openapi/openapi.json. # Complete redirect sign-in Source: https://docs.agg.market/api-reference/authentication/complete-redirect-sign-in /openapi/openapi.json post /auth/token/exchange Exchanges a one-time auth code for an access + refresh token pair. Auth codes are returned by OAuth/magic-link callback redirects. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose a sign-in provider](/recipes/authentication). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Fetch Turnstile site key Source: https://docs.agg.market/api-reference/authentication/fetch-turnstile-site-key /openapi/openapi.json get /bot-protection/site-key Returns the Cloudflare Turnstile site key registered for the app so clients can render the Turnstile widget anywhere in their UI. `siteKey` is `null` when no widget is linked to the app. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Handle sign-in challenges and verify tokens](/recipes/bot-protection). * **Next:** [Sign in and manage sessions](/concepts/authentication). # Refresh access token Source: https://docs.agg.market/api-reference/authentication/refresh-access-token /openapi/openapi.json post /auth/token/refresh Issues a new access + refresh token pair given a valid refresh token. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Sign in and manage sessions](/concepts/authentication). * **Next:** [Handle failures and retry safely](/concepts/errors). # Sign out Source: https://docs.agg.market/api-reference/authentication/sign-out /openapi/openapi.json post /auth/sign-out Revokes all tokens for the user and invalidates existing sessions. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Sign in and manage sessions](/concepts/authentication). * **Next:** [Handle failures and retry safely](/concepts/errors). # Start authentication Source: https://docs.agg.market/api-reference/authentication/start-authentication /openapi/openapi.json post /auth/start Initiates an auth flow. Required fields depend on the provider: | Provider | Required fields | Response type | |----------|----------------|---------------| | `google` | `redirectUrl` | `redirect` (OAuth URL) | | `twitter` | `redirectUrl` | `redirect` (OAuth URL) | | `email` | `email`, optionally `redirectUrl` | `magic_link` | | `siwe` | *(none)* | `nonce` | | `siws` | *(none)* | `nonce` | When bot protection is active, wallet/email auth may return `challenge_required`. Render a Cloudflare Turnstile widget using the returned `siteKey` and resubmit with the resulting `turnstileToken`. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose a sign-in provider](/recipes/authentication). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Verify sign-in Source: https://docs.agg.market/api-reference/authentication/verify-sign-in /openapi/openapi.json post /auth/verify Proves identity and returns an access + refresh token pair directly. The body is a union on `kind`: - **wallet** (default when `kind` is omitted) — verifies a signed SIWE/SIWS `message` and `signature`. - **`kind: "privy"`** — verifies a Privy-issued ES256 access token against the app's Privy JWKS. Available only when the app's wallet provider is Privy with credentials saved; the token must be issued by that same Privy app. Otherwise, or when the token is expired or invalid, the request is rejected with 401. Everything after identity is proven is shared across both: principal creation, the early-access gate, user creation, and token issuing. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose a sign-in provider](/recipes/authentication). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Verify Turnstile token Source: https://docs.agg.market/api-reference/authentication/verify-turnstile-token /openapi/openapi.json post /bot-protection/verify Verifies a Cloudflare Turnstile token against the app's linked widget. Intended to be called from **your backend**, not the browser — API keys are secrets and must never ship to the client. Both `x-app-id` and `x-app-api-key` are required; the server rejects the request with `401` if `x-app-id` does not match the app embedded in the API key. Create a key via `POST /apps/:appId/api-keys` (admin JWT required). Returns `{ success: true }` on a valid token or `403` if verification fails. Tokens are single-use (replay-protected). ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Handle sign-in challenges and verify tokens](/recipes/bot-protection). * **Next:** [Sign in and manage sessions](/concepts/authentication). # Find correlated markets by semantic similarity Source: https://docs.agg.market/api-reference/correlated-markets/find-correlated-markets-by-semantic-similarity /openapi/openapi.json post /correlated-markets/query Finds markets related to a plain-language query by matching it against each market's generated signals. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Discover related markets and outcome signals](/recipes/correlated-markets). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Find events connected via shared market signals Source: https://docs.agg.market/api-reference/correlated-markets/find-events-connected-via-shared-market-signals /openapi/openapi.json get /correlated-markets/connections/{venueMarketId} Returns events connected to a source market because they share generated market signals. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Discover related markets and outcome signals](/recipes/correlated-markets). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Get correlated market signals for a market Source: https://docs.agg.market/api-reference/correlated-markets/get-correlated-market-signals-for-a-market /openapi/openapi.json get /correlated-markets/{venueMarketId} Returns the generated Yes/No signals for a market: the real-world outcomes that become more or less likely if this market resolves. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Discover related markets and outcome signals](/recipes/correlated-markets). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Get ripple effect of a market resolving (by market ID) Source: https://docs.agg.market/api-reference/correlated-markets/get-ripple-effect-of-a-market-resolving-by-market-id /openapi/openapi.json get /correlated-markets/cascade/{venueMarketId} Returns markets that move with this one if it resolves (`mode=expand`) or offset it (`mode=hedge`). Takes an AGG `venueMarketId`; use the resolve endpoint when you only have a venue id, slug or text. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Discover related markets and outcome signals](/recipes/correlated-markets). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Resolve a market by venue ID, slug, or text and return cascade Source: https://docs.agg.market/api-reference/correlated-markets/resolve-a-market-by-venue-id-slug-or-text-and-return-cascade /openapi/openapi.json post /correlated-markets/resolve Finds a market from whatever identifier you have (venue + externalId, slug, conditionId, or free text) and returns its correlated-market candidates. Returns 202 while the candidates are still being generated; retry shortly. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Discover related markets and outcome signals](/recipes/correlated-markets). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Create a managed balance refill policy Source: https://docs.agg.market/api-reference/funding/create-a-managed-balance-refill-policy /openapi/openapi.json post /execution/balance-refill-policies Creates one refill policy for the signed-in user. Required: targetChainId, minimumRaw, and refillAmountRaw (6-decimal USD integer strings). Optional: targetTokenSymbol (USDC), dailyCapRaw, and maxFeeRaw. Authenticate with x-app-id and a user JWT. ## Integration guide * **Before you call:** [Fund an account and track withdrawals](/concepts/funding). * **Workflow:** [Configure managed balance refills](/recipes/managed-balance-refills). * **Next:** [Handle failures and retry safely](/concepts/errors). # Create a ramp widget session for checkout Source: https://docs.agg.market/api-reference/funding/create-a-ramp-widget-session-for-checkout /openapi/openapi.json post /ramp/session Opens a checkout widget. Send the quote amount and currencies plus serviceProvider and walletAddress, and an optional redirectUrl. The response includes id, token, and widgetUrl. Authenticate with x-app-id only. This is not GET /execution/deposit-addresses. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Open a fiat-to-crypto checkout](/recipes/onramp). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Get crypto purchase quotes Source: https://docs.agg.market/api-reference/funding/get-crypto-purchase-quotes /openapi/openapi.json post /ramp/quotes Quotes a fiat-to-crypto purchase. Send sourceAmount as a decimal string in sourceCurrencyCode units, destinationCurrencyCode, and an optional paymentMethodType. Authenticate with x-app-id only. This is not a deposit address and does not take a user JWT. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Open a fiat-to-crypto checkout](/recipes/onramp). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Get deposit addresses Source: https://docs.agg.market/api-reference/funding/get-deposit-addresses /openapi/openapi.json get /execution/deposit-addresses Returns the user's deposit addresses (EVM + Solana) and supported chains/tokens. Returns 202 while addresses are being prepared -- poll until 200. Returns 409 when the user's Privy wallet does not grant Agg's authorization key signing rights; the message says how to fix the wallet's signers. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Get withdrawal status Source: https://docs.agg.market/api-reference/funding/get-withdrawal-status /openapi/openapi.json get /execution/withdrawals/{withdrawalId} Returns the current persisted state of a withdrawal — used as a fallback when the WS lifecycle channel has missed events. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Handle failures and retry safely](/concepts/errors). # List managed balance refill policies Source: https://docs.agg.market/api-reference/funding/list-managed-balance-refill-policies /openapi/openapi.json get /execution/balance-refill-policies Lists the signed-in user's managed balance refill policies. Authenticate with x-app-id and a user JWT. Amounts are 6-decimal USD integer strings. Policy status is enabled or paused. ## Integration guide * **Before you call:** [Fund an account and track withdrawals](/concepts/funding). * **Workflow:** [Configure managed balance refills](/recipes/managed-balance-refills). * **Next:** [Handle failures and retry safely](/concepts/errors). # List recent managed balance refill attempts Source: https://docs.agg.market/api-reference/funding/list-recent-managed-balance-refill-attempts /openapi/openapi.json get /execution/balance-refill-policies/{policyId}/attempts Lists recent attempts for one policy. Attempt status is reserved, executing, completed, or failed. Authenticate with x-app-id and a user JWT. ## Integration guide * **Before you call:** [Fund an account and track withdrawals](/concepts/funding). * **Workflow:** [Configure managed balance refills](/recipes/managed-balance-refills). * **Next:** [Handle failures and retry safely](/concepts/errors). # Preview a withdrawal Source: https://docs.agg.market/api-reference/funding/preview-a-withdrawal /openapi/openapi.json post /execution/withdraw/preview Returns real fee + receive estimates for a withdrawal without creating one. Optional — /execution/withdraw does not require a prior preview. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Handle failures and retry safely](/concepts/errors). # Quote withdrawable amount Source: https://docs.agg.market/api-reference/funding/quote-withdrawable-amount /openapi/openapi.json post /execution/withdrawable/quote Returns the maximum deliverable amount for a token and destination chain without creating a withdrawal. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Handle failures and retry safely](/concepts/errors). # Update or pause a managed balance refill policy Source: https://docs.agg.market/api-reference/funding/update-or-pause-a-managed-balance-refill-policy /openapi/openapi.json patch /execution/balance-refill-policies/{policyId} Updates one policy's thresholds or sets status to enabled or paused. Authenticate with x-app-id and a user JWT. Amounts are 6-decimal USD integer strings. ## Integration guide * **Before you call:** [Fund an account and track withdrawals](/concepts/funding). * **Workflow:** [Configure managed balance refills](/recipes/managed-balance-refills). * **Next:** [Handle failures and retry safely](/concepts/errors). # Withdraw funds Source: https://docs.agg.market/api-reference/funding/withdraw-funds /openapi/openapi.json post /execution/withdraw Withdraws funds through the normal transfer/bridge planner. An optional requestId UUID makes exact-amount requests replayable: the same owner/key and parameters return the original lifecycle without dispatching twice; changed parameters return 409. Do not combine requestId with max. Continue polling the returned withdrawalId through terminal settlement. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get hosted venue account Source: https://docs.agg.market/api-reference/hosted-venue-accounts/get-hosted-venue-account /openapi/openapi.json get /users/me/venue-accounts/{venue} Returns the current user's hosted venue status and balance. Does not create a wallet. Provider identities and credentials are never returned. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get hosted venue account activity Source: https://docs.agg.market/api-reference/hosted-venue-accounts/get-hosted-venue-account-activity /openapi/openapi.json get /users/me/venue-accounts/{venue}/activity Returns one owned USDC ledger page with signed exact 18-decimal amounts and native activity codes. This is observed account activity, not order-fill accounting, a spendable balance, or proof that a particular withdrawal completed. No wallet is created and no funds move. Sandbox entries are simulated funds. Provider account identities, descriptions and credentials are not returned. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get hosted venue deposit address Source: https://docs.agg.market/api-reference/hosted-venue-accounts/get-hosted-venue-deposit-address /openapi/openapi.json get /users/me/venue-accounts/{venue}/deposit-address Returns the authenticated user's provider-verified Solana USDC deposit address. It never returns the provider wallet ID or credentials. The capability is independently gated from trading and may be unavailable even when orders are enabled. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get hosted venue withdrawal Source: https://docs.agg.market/api-reference/hosted-venue-accounts/get-hosted-venue-withdrawal /openapi/openapi.json get /users/me/venue-accounts/{venue}/withdrawals/{withdrawalId} Returns Agg's persisted state for one owned hosted withdrawal. This read never submits or retries a provider request and does not infer finality from an uncorrelated balance or ledger entry. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Provision hosted venue account Source: https://docs.agg.market/api-reference/hosted-venue-accounts/provision-hosted-venue-account /openapi/openapi.json post /users/me/venue-accounts/{venue} Explicitly requests a hosted account for live execution mode. Reuses the user's durable provisioning intent; a pending response never authorizes duplicate upstream creation. No private key is requested. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Withdraw hosted venue funds Source: https://docs.agg.market/api-reference/hosted-venue-accounts/withdraw-hosted-venue-funds /openapi/openapi.json post /users/me/venue-accounts/{venue}/withdrawals Submits one explicit-amount Solana USDC withdrawal under a caller-owned idempotency key. Agg durably records the exact request before dispatch. An ambiguous provider result is retained for manual review and is never automatically replayed. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Provision and fund a hosted venue account](/recipes/hosted-venue-accounts). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get bounded outcome snapshots Source: https://docs.agg.market/api-reference/market-data/get-bounded-outcome-snapshots /openapi/openapi.json get /orderbooks/outcomes Returns up to 100 outcome snapshots in request order. Missing, inaccessible, or stale/unverified outcomes are null. Healthy snapshots remain available when other outcomes are stale/unverified. Engine failures, or a batch with temporarily unavailable outcomes and no visible healthy snapshots, return 503. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # Get chart bars Source: https://docs.agg.market/api-reference/market-data/get-chart-bars /openapi/openapi.json get /charts/bars Returns OHLCV bars for a single venue market. ## Integration guide * **Before you call:** [Choose market-data reads and handle their limits](/api/market-data). * **Workflow:** [Load chart history and the live tail](/recipes/websocket-charts). * **Next:** [Build an event and market screen](/recipes/building-market-views). # Get live crypto reference prices Source: https://docs.agg.market/api-reference/market-data/get-live-crypto-reference-prices /openapi/openapi.json post /crypto/reference-prices Returns fresh reference prices for recurring crypto market targets. Prices are at most `hardMaxAgeMs` (1 second) old, except Chainlink-sourced prices, which are served from a live stream for up to 2 seconds because Chainlink publishes about once a second. A row past its limit comes back with `stale: true` and an error instead of a price. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Build recurring crypto market views](/recipes/live-crypto-markets). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # Get live midpoints for multiple markets Source: https://docs.agg.market/api-reference/market-data/get-live-midpoints-for-multiple-markets /openapi/openapi.json get /midpoints Returns live orderbook midpoints for the requested venue market IDs. `venueMarketIds` accepts either a comma-separated string (`?venueMarketIds=a,b,c`) or repeated params (`?venueMarketIds=a&venueMarketIds=b`). Midpoint represents the Yes-side price (0-1). Direct GET callers should keep batches small enough to stay under CDN URL limits; AGG SDK/UI clients default to 75 IDs per request and merge multiple batches automatically. Pass `bestPrice=true` to also receive top-of-book `bestBid`/`bestAsk` on every entry, per-outcome row, and matched sibling, plus `arbReturn` — the live, quality-gated cross-venue arbitrage return for the market's cluster, computed fresh by the engine on this request. Prefer it over the `arbReturn` embedded in venue-event listings, which is synced by a background job and can be stale. Every entry carries `marketStatus`; resolved markets never serve a price — they return null prices plus a structured `error` (`code: "market_resolved"`), and unknown ids return `code: "market_not_found"`, mirroring GET /orderbooks. Returns 503 if the orderbook engine is unavailable. ## Integration guide * **Before you call:** [Resolve venue identifiers and matched markets](/recipes/matched-clusters). * **Workflow:** [Compare prices across venues](/recipes/comparing-venue-prices). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Get live orderbook for a single outcome Source: https://docs.agg.market/api-reference/market-data/get-live-orderbook-for-a-single-outcome /openapi/openapi.json get /orderbook/outcome/{outcomeId} Returns the live single-venue orderbook for a specific venue market outcome, including the sticky tick and midpoint provenance (`markSource`). A nonempty book that cannot be verified within its freshness budget returns retryable 503 instead of stale liquidity. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # Get live orderbooks for multiple markets Source: https://docs.agg.market/api-reference/market-data/get-live-orderbooks-for-multiple-markets /openapi/openapi.json get /orderbooks Returns live per-venue orderbooks for the explicit venueMarketIds requested. `venueMarketIds` accepts either a comma-separated string (`?venueMarketIds=a,b,c`) or repeated params (`?venueMarketIds=a&venueMarketIds=b`). Use /orderbooks/outcomes for batches keyed by venue market outcome IDs. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # Get live sports scores Source: https://docs.agg.market/api-reference/market-data/get-live-sports-scores /openapi/openapi.json get /sports/live Returns normalized live score state for supported sports events. Event IDs are resolved through AGG's matched-event relationship to the Polymarket event used by the score provider. Unmatched, unsupported, and provider-unavailable events are listed in missingEventIds. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Build a sports market detail page](/recipes/sports-market-detail). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # Get versioned chart history references Source: https://docs.agg.market/api-reference/market-data/get-versioned-chart-history-references /openapi/openapi.json get /charts/history/manifest Returns at most eight authorized CDN chunks, effective resolution, finalization boundary and delivery state. Fetch the unfinished tail with /charts/bars. ## Integration guide * **Before you call:** [Choose market-data reads and handle their limits](/api/market-data). * **Workflow:** [Load chart history and the live tail](/recipes/websocket-charts). * **Next:** [Build an event and market screen](/recipes/building-market-views). # Get the first useful event view Source: https://docs.agg.market/api-reference/markets/get-the-first-useful-event-view /openapi/openapi.json get /venue-events/{id}/bootstrap Event display data and the first bounded market slice share one cluster resolution. Further market pages, histories and personalized execution load independently. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Build an event and market screen](/recipes/building-market-views). # Get the per-app UI config (disabled venues + categories) Source: https://docs.agg.market/api-reference/markets/get-the-per-app-ui-config-disabled-venues-+-categories /openapi/openapi.json get /app/config Public lookup of one app's UI config by x-app-id. No user session and no API key. Returns the venues and category presets the app has turned off, whether early access is on, the enabled auth providers, and the app's trading mode. A missing x-app-id is 400. An unknown or inactive app is 404. This does not include secrets. A successful response may be cached at the edge until the app changes. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Build an event and market screen](/recipes/building-market-views). * **Next:** [Check regional trading availability](/recipes/venue-geo-availability). # Get Venue Event Source: https://docs.agg.market/api-reference/markets/get-venue-event /openapi/openapi.json get /venue-events/{id} Returns a single venue event. The response may include an embedded `venueMarkets` array. That array is deprecated — fetch the event's markets from GET /venue-markets?venueEventId=. `marketCount` and the rolled-up `volume` cover the event's whole matched cluster by default. `marketCount` is the number of rows GET /venue-markets?venueEventId= returns for this event (open markets, matched siblings folded into their anchor), so it can be paged to exactly. `volume` sums EVERY open and closed market in the cluster including those folded siblings, so it is not a total over those rows. Send `cluster=false` to scope both to this venue event alone. The embedded `venueMarkets` array is always this one event's markets regardless of `cluster`. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Build an event and market screen](/recipes/building-market-views). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # List categories Source: https://docs.agg.market/api-reference/markets/list-categories /openapi/openapi.json get /categories Returns categories filtered by parentId. Omit parentId for top-level categories. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Build an event and market screen](/recipes/building-market-views). # List recurring crypto window markets Source: https://docs.agg.market/api-reference/markets/list-recurring-crypto-window-markets /openapi/openapi.json get /crypto/recurring-markets Returns deterministic crypto window markets grouped by asset, window duration, and derived window start across supported venues. Scope is limited to 5 minute, 15 minute, 1 hour, and 1 day recurring crypto markets. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Build recurring crypto market views](/recipes/live-crypto-markets). * **Next:** [Subscribe to live orderbook updates](/recipes/websocket-orderbook). # List venue events Source: https://docs.agg.market/api-reference/markets/list-venue-events /openapi/openapi.json get /venue-events Returns paginated venue events with optional filters for venue, status, category, and search. DEPRECATION: the per-item `description` field is deprecated and will be removed from list responses — fetch the full event (including `description`) via GET /venue-events/:id. The embedded `venueMarkets` on each item are a top-N preview only; for the full market list use GET /venue-markets?venueEventId=. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Build an event and market screen](/recipes/building-market-views). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # List venue markets Source: https://docs.agg.market/api-reference/markets/list-venue-markets /openapi/openapi.json get /venue-markets Use projection=summary to omit description/rules text while preserving market ordering and trading metadata; markets and siblings are marked detailsDeferred=true. Use projection=full for demand-loaded text. Projection is independent of context and defaults to existing response behavior. Returns paginated venue markets with optional filters for venue, status, category, and date range. When `venueEventId` is set, the response covers that event's ENTIRE matched cluster by default — every venue's markets for the same real-world event, with matched siblings folded into their anchor row rather than repeated. Naming any member of a cluster returns the same page, and the ordering does not depend on which member you name. This is the only way to reach markets that exist on a single venue and therefore have no cross-venue counterpart. Send `cluster=false` for the legacy behaviour (that one venue event's own markets), or `venue=` to keep the cluster scope but narrow to one venue. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Build an event and market screen](/recipes/building-market-views). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Look up venue market outcomes by external identifier Source: https://docs.agg.market/api-reference/markets/look-up-venue-market-outcomes-by-external-identifier /openapi/openapi.json get /venue-market-outcomes Resolves venue-side outcome keys (e.g. Polymarket token_id) to AGG outcomes, including `matchedVenueMarketOutcomes` counterpart references across venues. The referenced markets are returned alongside `data` as `matchedVenueMarkets[]` — the same shape /venue-markets nests on each market — so a trade flow resolves the counterpart's venue and venue-side ids from this one response. Note a Kalshi No-side outcome has no `externalIdentifier`; its ticker is the sibling market's `externalIdentifier`. Batch up to 20 keys. Outcome keys are globally unique, so `venue` is optional and narrows only. Kalshi carries its ticker on exactly one of the two outcomes per market, so Kalshi No-side outcomes cannot be resolved here — fetch the market via GET /venue-markets?venue=kalshi&externalIdentifier= and read both outcomes from it. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Resolve venue identifiers and matched markets](/recipes/matched-clusters). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Search events or markets Source: https://docs.agg.market/api-reference/markets/search-events-or-markets /openapi/openapi.json get /search Hybrid search (trigram + FTS + vector) across events or markets. Returns results ranked by relevance. Use listing endpoints for filtered discovery. The embedded `venueMarkets` on each event result are a top-N preview only; for the full market list use GET /venue-markets?venueEventId=. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Choose market-data reads and handle their limits](/api/market-data). * **Next:** [Build an event and market screen](/recipes/building-market-views). # Article-centric news feed for a category - latest articles with linked markets and live prices Source: https://docs.agg.market/api-reference/news/article-centric-news-feed-for-a-category--latest-articles-with-linked-markets-and-live-prices /openapi/openapi.json get /news-feed/{category} Pages articles for one category id from GET /news-feed. Query cursor, since, before, and limit (1 to 50). Authenticate with x-app-id only. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Add market-linked news feeds](/recipes/news-feed). * **Next:** [Discover related markets and outcome signals](/recipes/correlated-markets). # Get news articles for markets with market impact summaries Source: https://docs.agg.market/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries /openapi/openapi.json post /news-feed/market-news Returns articles for up to 10 venueMarketIds or venueEventIds, or for customQuery. Optional tbs time bound and limit (1 to 5). Authenticate with x-app-id only. Start here when you already know the market. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Add market-linked news feeds](/recipes/news-feed). * **Next:** [Discover related markets and outcome signals](/recipes/correlated-markets). # List available news feeds with IDs and item counts Source: https://docs.agg.market/api-reference/news/list-available-news-feeds-with-ids-and-item-counts /openapi/openapi.json get /news-feed Lists feed ids, names, and item counts. Use a feed id as the category path parameter on the article and market feeds. Authenticate with x-app-id only. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Add market-linked news feeds](/recipes/news-feed). * **Next:** [Discover related markets and outcome signals](/recipes/correlated-markets). # Market-centric news feed - markets grouped with their news timeline Source: https://docs.agg.market/api-reference/news/market-centric-news-feed--markets-grouped-with-their-news-timeline /openapi/openapi.json get /news-feed/{category}/markets Pages markets in one category, each with its article timeline. Query cursor, since, before, and limit (1 to 50). Authenticate with x-app-id only. category is a feed id from GET /news-feed. ## Integration guide * **Before you call:** [Understand events, markets, and outcomes](/concepts/markets). * **Workflow:** [Add market-linked news feeds](/recipes/news-feed). * **Next:** [Discover related markets and outcome signals](/recipes/correlated-markets). # Create a paper trading account Source: https://docs.agg.market/api-reference/paper-trading/create-a-paper-trading-account /openapi/openapi.json post /apps/{appId}/paper-trading/accounts Creates a simulated account for this app. Authenticate with x-app-api-key only. This is the admin path. Users paper-trade by sending mode=paper on the quote and the fill, not on this route. Optional name, externalId, and a starting balance as initialBalanceRaw (integer string) or initialBalance (display number). ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Get a paper trading account Source: https://docs.agg.market/api-reference/paper-trading/get-a-paper-trading-account /openapi/openapi.json get /apps/{appId}/paper-trading/accounts/{accountId} Returns one paper account. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Get a paper trading portfolio Source: https://docs.agg.market/api-reference/paper-trading/get-a-paper-trading-portfolio /openapi/openapi.json get /apps/{appId}/paper-trading/accounts/{accountId}/portfolio Returns cash, equity, and positions for one paper account. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # List paper trading accounts Source: https://docs.agg.market/api-reference/paper-trading/list-paper-trading-accounts /openapi/openapi.json get /apps/{appId}/paper-trading/accounts Lists this app's paper accounts. Authenticate with x-app-api-key only. Filter with externalId. Page with limit and cursor. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # List paper trading orders Source: https://docs.agg.market/api-reference/paper-trading/list-paper-trading-orders /openapi/openapi.json get /apps/{appId}/paper-trading/accounts/{accountId}/orders Lists paper orders for the account. Authenticate with x-app-api-key only. Page with limit and cursor. clientOrderId is stored for traceability and is not an idempotency key. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # List paper trading positions Source: https://docs.agg.market/api-reference/paper-trading/list-paper-trading-positions /openapi/openapi.json get /apps/{appId}/paper-trading/accounts/{accountId}/positions Lists paper positions for the account. Authenticate with x-app-api-key only. Page with limit and cursor. Once a market has settled, use winner rather than markPrice. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Place a paper trading order Source: https://docs.agg.market/api-reference/paper-trading/place-a-paper-trading-order /openapi/openapi.json post /apps/{appId}/paper-trading/accounts/{accountId}/orders Places a simulated order on venueMarketOutcomeId. Buy with spendRaw or spend; sell with shares. clientOrderId is stored for traceability and is not an idempotency key. Authenticate with x-app-api-key only. This is not POST /execution/orders. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Reset a paper trading account Source: https://docs.agg.market/api-reference/paper-trading/reset-a-paper-trading-account /openapi/openapi.json post /apps/{appId}/paper-trading/accounts/{accountId}/reset Resets the account and sets a new cash balance. Send balanceRaw (integer string) or balance (display number), and an optional reason. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Set a paper trading account cash balance Source: https://docs.agg.market/api-reference/paper-trading/set-a-paper-trading-account-cash-balance /openapi/openapi.json patch /apps/{appId}/paper-trading/accounts/{accountId}/balance Sets the account's cash balance. Send balanceRaw (integer string) or balance (display number), and an optional reason. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Create and use paper trading accounts](/recipes/paper-trading). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Get analytics Source: https://docs.agg.market/api-reference/partner-admin/get-analytics /openapi/openapi.json get /apps/{appId}/analytics Returns user and order totals, current cash and position values, and cumulative fees for this app. usersLast7Days, usersLast30Days, ordersLast7Days, and ordersLast30Days cover the named rolling windows. Authenticate with an app-scoped x-app-api-key. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user Source: https://docs.agg.market/api-reference/partner-admin/get-user /openapi/openapi.json get /apps/{appId}/users/{userId} Returns the profile of one user in this app. Authenticate with an app-scoped x-app-api-key. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user balances Source: https://docs.agg.market/api-reference/partner-admin/get-user-balances /openapi/openapi.json get /apps/{appId}/users/{userId}/balances Reads one user's balances. Authenticate with x-app-api-key only. A read key can read any user of your app. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user deposits Source: https://docs.agg.market/api-reference/partner-admin/get-user-deposits /openapi/openapi.json get /apps/{appId}/users/{userId}/deposits Reads one user's deposits. amountRaw is an integer string in the token's decimals. Page with limit and cursor. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user orders Source: https://docs.agg.market/api-reference/partner-admin/get-user-orders /openapi/openapi.json get /apps/{appId}/users/{userId}/orders Order history for one user of your app, newest first. Filter with `status`, `orderId`, `quoteId` and `venueMarketIds` (comma-separated or repeated); legs of one multi-venue trade share a `quoteId`. Authenticate with an `x-app-api-key` of either scope: a `read` key can read any user of your app, so treat it as a credential over your users' full trading history. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user positions Source: https://docs.agg.market/api-reference/partner-admin/get-user-positions /openapi/openapi.json get /apps/{appId}/users/{userId}/positions Open and closed positions for one user of your app. Filter with `venueEventId`, `venueMarketId` and `status`. Positions are reported per venue leg and marked at the user's average entry price — this endpoint does not consult an orderbook, so it returns no live mark or unrealized PnL. Authenticate with an `x-app-api-key` of either scope: a `read` key can read any user of your app, so treat it as a credential over your users' full trading history. Filter by `categoryIds` and sort with `sort=realizedPnl` (biggest wins: `status=closed&sort=realizedPnl`). Each outcome carries `realizedPnl`. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get user withdrawals Source: https://docs.agg.market/api-reference/partner-admin/get-user-withdrawals /openapi/openapi.json get /apps/{appId}/users/{userId}/withdrawals Reads one user's withdrawals. amountRaw is an integer string in the token's decimals. Page with limit and cursor. Authenticate with x-app-api-key only. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # List orders across your app Source: https://docs.agg.market/api-reference/partner-admin/list-orders-across-your-app /openapi/openapi.json get /apps/{appId}/orders Every order placed by any user of your app, as a changes feed: ordered by last update, oldest first. Page until `hasMore` is false, then store `nextCursor` and pass it as `cursor` on your next run to receive only orders created or changed since. `nextCursor` is returned on every response, including an empty one. An order is returned again each time it changes (for example `pending` → `filled`), so upsert by `id`. Orders changed in the last 2 minutes appear on a later run. Filter with `status`, a comma-separated list such as `filled,partial_fill`. Authenticate with an `x-app-api-key` of either scope: a `read` key can read every user's orders in your app, so treat it as a credential over your users' full trading history. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # List users Source: https://docs.agg.market/api-reference/partner-admin/list-users /openapi/openapi.json get /apps/{appId}/users Lists users of this app. Filter with search and paginate with limit (1 to 100) and cursor. Authenticate with an app-scoped x-app-api-key. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # List users affected by market resolution Source: https://docs.agg.market/api-reference/partner-admin/list-users-affected-by-market-resolution /openapi/openapi.json get /apps/{appId}/market-resolutions/{venueMarketId}/users Lists users of this app who hold the venue market. Set includeEmails=true to include email. Paginate with limit and cursor. Authenticate with an app-scoped x-app-api-key. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). * **Next:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). # Get balances Source: https://docs.agg.market/api-reference/portfolio/get-balances /openapi/openapi.json get /execution/balances Returns per-chain token balances and per-venue position balances for the authenticated user. Pass `custody=self` for the wallets linked to this account (and their Polymarket deposit and legacy wallets) instead of the managed server wallet; each chain row then carries `address` and `custodyKind`. Positions are account-wide in both modes. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Fund an account and track withdrawals](/concepts/funding). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Get user activity feed Source: https://docs.agg.market/api-reference/portfolio/get-user-activity-feed /openapi/openapi.json get /users/activity Returns a unified, timestamp-ordered feed of the authenticated user's trades, withdrawals, standalone bridges, deposits, and low-level wallet operations. Cursor-based pagination. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Display user activity and rankings](/recipes/portfolio). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Get user orders Source: https://docs.agg.market/api-reference/portfolio/get-user-orders /openapi/openapi.json get /execution/orders Returns paginated orders for the authenticated user with venue market details. Filter with `status`, `orderType`, `orderId`, `quoteId`, `externalId`, `clientOrderId` and `venueMarketIds` (comma-separated or repeated). ## Integration guide * **Before you call:** [Submit a quote for execution](/concepts/execution). * **Workflow:** [Track orders to a final state](/concepts/order-lifecycle). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get user positions Source: https://docs.agg.market/api-reference/portfolio/get-user-positions /openapi/openapi.json get /execution/positions Returns open positions grouped by matched market with per-venue breakdown and current pricing. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Track orders to a final state](/concepts/order-lifecycle). * **Next:** [Read settlement results and claim payouts](/concepts/market-resolution). # Trader leaderboard Source: https://docs.agg.market/api-reference/portfolio/trader-leaderboard /openapi/openapi.json get /leaderboard Users of this app ranked by realizedPnl (default) or volume over an inclusive UTC day range; omit from/to for all time. rank is exact within the top 100 and null beyond. Identity fields follow the app's leaderboardIdentity setting. Pass exactly one of userId, externalId, username or walletAddress to look up a single user. realizedPnl is gross, position-level realized P&L in USD: sell proceeds against the position's average entry, plus settlement payout against entry, attributed to the order's creation day (sells) or the settlement day. It excludes venue and app fees. Positions with an unknown cost basis (imported inventory) contribute 0. History: volume and order counts are complete; P&L is complete from the deploy date, and earlier settled positions carry their lifetime P&L on their redemption day. Data refreshes every 5 minutes. Filters: `categoryIds` (one category, including its sub-categories), `venueEventIds` (up to 5 events, each merged across venues; all time only), `userIds` (up to 100 users, unpaginated). wins/losses count settled position legs: a leg whose settlement booked a profit is a win, a loss is a loss, and a zero settlement (void, refund at cost, or a position with no known entry price) is neither. A position fully sold before settlement has no settlement, so it counts toward realizedPnl only; a leg partly sold and then settled is classified by its settlement alone. winRate = wins / (wins + losses), null before any settlement. A trade counts in the categories of the event it was placed on (its venue's own event), while venueEventIds expands to the same event on every venue. Category rows overlap (an NFL game counts in nfl, football and sports), so never add two categories' numbers together. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Display user activity and rankings](/recipes/portfolio). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Trader leaderboard series Source: https://docs.agg.market/api-reference/portfolio/trader-leaderboard-series /openapi/openapi.json get /leaderboard/series Leaderboard stats per UTC day — orders, volume, spent, realizedPnl, wins, losses and winRate, plus running cumulativeRealizedPnl and cumulativeWinRate and range totals — oldest first, ready to chart in one call. Pass at most one of userId, externalId, username or walletAddress (username and walletAddress follow the app's leaderboardIdentity setting, as on /leaderboard); with none, the series is the whole app's daily totals. Optional categoryIds limits it to one category. Defaults to the last 30 days ending today; at most 366 days; a `to` after today is clamped to today. Every day in the range is returned, days without activity as 0. cumulativeRealizedPnl starts from 0 at `from`. Each day's fields match /leaderboard for that one-day range exactly. realizedPnl is gross, position-level realized P&L in USD (sells against average entry on the order's creation day, settlement payout against entry on the settlement day), excluding venue and app fees. Unrealized P&L on open positions is not included. Positions with an unknown cost basis contribute 0. P&L is complete from the leaderboard deploy date; earlier settled positions carry their lifetime P&L on their redemption day. Data refreshes every 5 minutes. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Display user activity and rankings](/recipes/portfolio). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Cancel an order Source: https://docs.agg.market/api-reference/trading/cancel-an-order /openapi/openapi.json post /execution/orders/cancel Send `orderId` to cancel an agg order. Send `venue`, `venueOrderId`, `signerAddress` and `cancelSignature` to cancel a Polymarket or Hyperliquid limit order that one of your linked self-custody wallets placed outside AGG. `signerAddress` must be a wallet linked to this user and must be the wallet that produced `cancelSignature`. For an external order AGG reads the order from the venue, cancels it, and only after the venue confirms records it as a `cancelled` order; the returned `orderIds[0]` appears in `GET /execution/orders?orderId=`. Fills that happened before the cancel are not recorded by AGG. An order that is already filled, cancelled or not owned by the wallet returns 409. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Place, track, and cancel limit orders](/recipes/limit-orders). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Execute quote Source: https://docs.agg.market/api-reference/trading/execute-quote /openapi/openapi.json post /execution/fill Accepts a previously quoted smart-route quote and returns pending order IDs for the submitted fill execution. ## Integration guide * **Before you call:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). * **Workflow:** [Quote, execute, and track a trade](/quickstart/rest). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Get a quote (smart route) Source: https://docs.agg.market/api-reference/trading/get-a-quote-smart-route /openapi/openapi.json get /orderbook/{venueMarketOutcomeId}/route Computes a fill quote across available venues for a specific venue market outcome and returns the suggested fills. When a user JWT is supplied the quote is scoped to the user's wallet balances and may be executed; without a JWT the response is a preview-only quote. Anonymous ProphetX preview prices come from the Clearinghouse without a synchronous account-readiness check. Account identity and readiness are checked before a live fill is admitted for execution, before funding or execution is queued. ProphetX quotes apply no execution reserve, settlement surcharge, or speculative venue commission. ProphetX live buys use fixed-input FOK execution and a maximum $500 per-order input cap. ## Integration guide * **Before you call:** [Choose the correct event, market, and outcome IDs](/concepts/ids). * **Workflow:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). * **Next:** [Submit a quote for execution](/concepts/execution). # Get execution status Source: https://docs.agg.market/api-reference/trading/get-execution-status /openapi/openapi.json get /execution/status Returns quote-scoped execution progress, execution-plan step state, and terminal order leg results. ## Integration guide * **Before you call:** [Submit a quote for execution](/concepts/execution). * **Workflow:** [Track orders to a final state](/concepts/order-lifecycle). * **Next:** [Handle failures and retry safely](/concepts/errors). # Get the per-venue blocked-country lists (ISO 3166-1 alpha-2) Source: https://docs.agg.market/api-reference/trading/get-the-per-venue-blocked-country-lists-iso-3166-1-alpha-2 /openapi/openapi.json get /app/venue-geo-policy Returns each venue's trading country policy. blockedCountries is where the venue cannot be traded. When allowedCountries is present the venue can be traded only from those countries, and an empty blockedCountries list is not 'unrestricted'. Authenticate with x-app-id. Quote and fill responses also carry non-blocking warnings; this list is the policy itself. ## Integration guide * **Before you call:** [Set up your app and request headers](/environments). * **Workflow:** [Check regional trading availability](/recipes/venue-geo-availability). * **Next:** [Read quote prices, expiry, fees, and warnings](/concepts/quotes). # Place an order directly Source: https://docs.agg.market/api-reference/trading/place-an-order-directly /openapi/openapi.json post /execution/orders Places a market order on a single named venue without a prior quote. The route is priced and funded server-side and always produces exactly one order. `externalId` is required and unique within your app — two different users of the same app cannot share one — so retrying a timed-out request with the same value returns 409 instead of trading twice. A request rejected before its order was queued (for example on a balance check) does not use up the `externalId`; resend with the same value. Live only: an app in paper trading mode gets 400 with `paper_mode_unsupported`. ## Integration guide * **Before you call:** [Fund an account and track withdrawals](/concepts/funding). * **Workflow:** [Place and track a direct order](/recipes/direct-order-execution). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Place limit order Source: https://docs.agg.market/api-reference/trading/place-limit-order /openapi/openapi.json post /execution/limit-orders Places a venue-specific limit order. Managed orders reserve funds or shares. Self-custody buys can fund across supported chains using the existing client-signature flow; poll quoteId until the order is open or terminal. Fills are reconciled asynchronously. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Place, track, and cancel limit orders](/recipes/limit-orders). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Redeem winnings for resolved positions Source: https://docs.agg.market/api-reference/trading/redeem-winnings-for-resolved-positions /openapi/openapi.json post /execution/redeem Claims a user's settled positions as USDC: winning shares, and split payouts such as a Polymarket 50/50. EVM venue redemptions need no user wallet prompt and complete asynchronously; listen for redeem_event on the WebSocket. Venues that settle on their own need no redeem call, and their positions report redeemStatus ineligible or redeemed. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Read settlement results and claim payouts](/concepts/market-resolution). * **Next:** [Fund an account and track withdrawals](/concepts/funding). # Submit signatures for a pending self-custody fill Source: https://docs.agg.market/api-reference/trading/submit-signatures-for-a-pending-self-custody-fill /openapi/openapi.json post /execution/fill/{quoteId}/signatures Accepts client-produced signatures for a self-custodial fill. POST /execution/fill never returns the payloads to sign directly — signature requests are produced asynchronously, after the fill response is sent. Poll GET /execution/status for `pendingSignatures` instead, sign each payload, then submit them here. Returns the next batch (empty when done). ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Answer wallet signature requests](/recipes/self-custody-signing). * **Next:** [Track orders to a final state](/concepts/order-lifecycle). # Confirm account linking Source: https://docs.agg.market/api-reference/users/confirm-account-linking /openapi/openapi.json post /users/me/link-account/confirm Finalizes an account link. Body is a union on `kind`: the `token` branch (default when `kind` is omitted, for backward compatibility with existing OAuth/email callers) exchanges the short-lived `link_confirm_token` (handed to the partner app by the link callback redirect) for an actual Account row attached to the current user's principal — the server matches the bearer's principalId AND appId to the token's values before writing, the final ownership check. The `wallet_signature` branch (`kind: "wallet_signature"`) instead verifies the signature against the exact `message` returned by `/link-account/start`'s `wallet_challenge`. Either path: a collision with an existing Account attached to a different Principal returns HTTP 409; the 200 success shape only covers `linked` and `already_linked_same`. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Link or disconnect sign-in providers](/recipes/account-linking). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Delete a venue key Source: https://docs.agg.market/api-reference/users/delete-a-venue-key /openapi/openapi.json delete /venue-keys/{venue} Deletes the authenticated user's stored key for a venue. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Connect the user's venue credentials](/recipes/venue-keys). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Disconnect auth provider Source: https://docs.agg.market/api-reference/users/disconnect-auth-provider /openapi/openapi.json delete /users/me/accounts/{provider} Removes a linked auth provider from the current user's identity. Cannot disconnect the last remaining provider (would lock the user out). When several accounts share a provider — the normal case for linked wallets — pass `?providerAccountId=` to name the one to remove; omitting it is a 400 rather than an arbitrary pick. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Link or disconnect sign-in providers](/recipes/account-linking). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Get avatar upload URL Source: https://docs.agg.market/api-reference/users/get-avatar-upload-url /openapi/openapi.json post /users/me/avatar Returns a presigned S3 URL for the user to upload their avatar. Allowed types: JPEG, PNG, GIF, WebP. Max size: 5 MB. After upload, the image is automatically processed to WebP and served via CDN. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Upload, confirm, or remove an avatar](/recipes/avatar-upload). * **Next:** [Link or disconnect sign-in providers](/recipes/account-linking). # Get current user Source: https://docs.agg.market/api-reference/users/get-current-user /openapi/openapi.json get /users/me Returns the authenticated user's profile. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Link or disconnect sign-in providers](/recipes/account-linking). * **Next:** [Link your partner user ID](/recipes/external-id-linking). # Get shared KYC status Source: https://docs.agg.market/api-reference/users/get-shared-kyc-status /openapi/openapi.json get /kyc/status Checks the clearinghouse verification record for the authenticated user's managed Solana wallet. Shared by Novig and ProphetX; Kalshi v2 does not require KYC. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Check identity verification and start KYC](/recipes/kyc). * **Next:** [Place and track a direct order](/recipes/direct-order-execution). # Get venue key connection status Source: https://docs.agg.market/api-reference/users/get-venue-key-connection-status /openapi/openapi.json get /venue-keys/{venue}/status Returns whether the authenticated user has a validated, connected key for a venue. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Connect the user's venue credentials](/recipes/venue-keys). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Initiate KYC Source: https://docs.agg.market/api-reference/users/initiate-kyc /openapi/openapi.json post /kyc/{venue} Starts shared clearinghouse KYC for Novig or ProphetX. Already-verified wallets return verified without a new session or signature. Kalshi v2 returns not_applicable. The backend callback redirects to redirectUri after checking authoritative status. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Check identity verification and start KYC](/recipes/kyc). * **Next:** [Place and track a direct order](/recipes/direct-order-execution). # Link partner external user ID (HMAC-verified) Source: https://docs.agg.market/api-reference/users/link-partner-external-user-id-hmac-verified /openapi/openapi.json post /users/me/external-id Verifies a partner-signed external user ID assertion and stores the linked external ID on the current user. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Link your partner user ID](/recipes/external-id-linking). * **Next:** [Read app-wide users, activity, and analytics](/recipes/partner-admin). # List venue keys Source: https://docs.agg.market/api-reference/users/list-venue-keys /openapi/openapi.json get /venue-keys Lists the authenticated user's stored venue key summaries, optionally filtered to specific venues. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Connect the user's venue credentials](/recipes/venue-keys). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Start account linking Source: https://docs.agg.market/api-reference/users/start-account-linking /openapi/openapi.json post /users/me/link-account/start Initiates an account-link flow. Authenticated-only. For OAuth providers returns a provider redirect URL and sets an HttpOnly proof cookie that is required at callback time. The same-browser guarantee plus a mode discriminator on the state JWT prevents a phished link URL from attaching a victim's identity to the attacker's principal. For wallet providers (siwe/siws) returns a `wallet_challenge` with a `message` that the client MUST sign verbatim and pass to `/link-account/confirm` — do not reconstruct the message client-side — the server compares it byte-for-byte at confirm. The message names the account (username, masked email, or linked handle) so a phished user can see it is not theirs; a principal with no such identity is refused with `no_identity_for_link_challenge`. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Link or disconnect sign-in providers](/recipes/account-linking). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Update username Source: https://docs.agg.market/api-reference/users/update-username /openapi/openapi.json patch /users/me Updates the current user's username. Must be 3-30 characters. Letters, numbers, and `_ - .` allowed; must start and end with a letter or number. Returns 409 if the username is already taken. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Upload, confirm, or remove an avatar](/recipes/avatar-upload). * **Next:** [Link or disconnect sign-in providers](/recipes/account-linking). # Upsert a venue API key Source: https://docs.agg.market/api-reference/users/upsert-a-venue-api-key /openapi/openapi.json put /venue-keys Stores (or replaces) the authenticated user's encrypted venue credentials. ## Integration guide * **Before you call:** [Sign in and manage sessions](/concepts/authentication). * **Workflow:** [Connect the user's venue credentials](/recipes/venue-keys). * **Next:** [Prepare a self-custody trade](/recipes/self-custody). # Replay failed deliveries Source: https://docs.agg.market/api-reference/webhooks/replay-failed-deliveries /openapi/openapi.json post /apps/{appId}/webhooks/{webhookId}/replay Re-queues failed deliveries for this endpoint. Authenticate with x-app-api-key only. Send an empty JSON body. The response is { success }. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). * **Next:** [Verify and deduplicate webhook deliveries](/recipes/webhooks/verifying-signatures). # Rotate webhook signing secret Source: https://docs.agg.market/api-reference/webhooks/rotate-webhook-signing-secret /openapi/openapi.json post /apps/{appId}/webhooks/{webhookId}/rotate-secret Issues a new signing secret for this endpoint. The secret is returned once and cannot be read back later. Authenticate with x-app-api-key only. Send an empty JSON body. ## Integration guide * **Before you call:** [Create a server API key](/environments#server-api-keys). * **Workflow:** [Configure webhook delivery, rotation, and replay](/recipes/webhooks/overview). * **Next:** [Verify and deduplicate webhook deliveries](/recipes/webhooks/verifying-signatures). # App-events Source: https://docs.agg.market/api-reference/websockets/app-events Every user event of the connection's app, for backends. Requires an `x-app-api-key` issued for this app on the handshake. Send `{ action: "subscribe", channel: "app-events", resumeFrom? }`. Frames match the user-event channel, always carry `userId`, and are stamped with the app stream's `eventId`. # Arb Source: https://docs.agg.market/api-reference/websockets/arb Subscribe to per-market arb-return updates. Send `{ action: "subscribe", channel: "arb", marketIds: string[] }`. The server echos subscribed marketIds in the `subscribed` reply. No initial snapshot — updates arrive as `arb_market_update` messages when the engine detects a change. # Arb-feed Source: https://docs.agg.market/api-reference/websockets/arb-feed Subscribe to the full arb-return broadcast feed. Send `{ action: "subscribe", channel: "arb-feed" }` (no ids required). Receives batched `arb_feed_batch` messages for all markets whenever the engine publishes arb updates. # Auth Source: https://docs.agg.market/api-reference/websockets/auth Authenticate mid-session to receive user-specific events. Send your JWT token (same as REST API) to upgrade from app-level to user-level auth. Supports re-authentication for token refresh or user switch. Pass `resumeFrom` (the last `eventId` processed) to replay user events missed while disconnected; a `resumed` frame ends the replay. `auth_expiring` warns before the token expires; `auth_expired` means the session dropped to app level. **Connect-time auth (deprecated):** `?token=eyJ...` as a query param alongside `appId`. # Orderbook Source: https://docs.agg.market/api-reference/websockets/orderbook Subscribe to aggregated orderbook updates for canonical markets. Receive an initial snapshot, then incremental deltas with sequencing and checksums. Client maintains a local book by applying deltas. Request a resnapshot if your local state drifts. **Sequencing:** `seq` increments between consecutive messages for a given market. If you receive a `seq` that is not your expected next value, request a new snapshot via `resnapshot`. # Trades Source: https://docs.agg.market/api-reference/websockets/trades Subscribe to public trade feed for outcomes. No user auth required. Uses { action: "subscribe", channel: "trades", outcomeIds } message format. Shares the 500-subscription limit with orderbook subscriptions. # User events Source: https://docs.agg.market/api-reference/websockets/userevents User-specific events (requires user-level auth via authenticate action or token query param). Streams order confirmations, balance updates, and a venue-agnostic order lifecycle event (`order_event`) covering execution-plan steps and terminal fill / failure status across all venues. # Market-data API Source: https://docs.agg.market/api/market-data Choose bootstrap, chart history, search mode, or the book id for a market-data read. Market-data reads require `x-app-id`; a user access token is optional. Server-side clients can also send an API key to use its rate-limit budget. Keep API keys on your backend. See [Server API keys](/environments#server-api-keys). | Decision | Use this | Not this | | - | - | - | | First paint of an event | [`GET /venue-events/{id}/bootstrap`](#bootstrap) | [`GET /venue-events/{id}`](/api-reference/markets/get-venue-event) plus [`GET /venue-markets?venueEventId=`](/api-reference/markets/list-venue-markets) when you need a filterable market page instead of the first slice | | Chart history | [`GET /charts/history/manifest`](#charts), then [`GET /charts/bars`](#charts) for the unfinished tail | Bars alone when a ready manifest already covers the prefix, or the manifest alone when history is not finalized through `to` | | Search | [`GET /search?mode=instant`](#search) for typeahead, `mode=full` for a submitted search | The list endpoints, which are for ordinary filtered browsing | | Books | Outcome id on [`GET /orderbooks/outcomes`](#order-books), [`GET /orderbook/outcome/{outcomeId}`](#order-books), and the [orderbook socket](/recipes/websocket-orderbook) | Market id on [`GET /orderbooks`](#order-books) and [`GET /midpoints`](/recipes/comparing-venue-prices). There is no midpoint socket | ## Bootstrap `GET /venue-events/{id}/bootstrap` is the first useful event view: event display fields, the first market slice, and the outcome ids to subscribe. Optional `limit` is an integer from 1 to 20. The slice is not the full event. Page the rest with [List venue markets](/api-reference/markets/list-venue-markets) using the returned cursor. Rules, wallets, quotes, and offscreen pages are separate reads. `selectedOutcomeIds` are outcome ids for chart history and the orderbook channel, not venue market ids. `references` points at the history manifest and that live channel. `detailsDeferred` is true: descriptions and rules may need a later read. `freshness.assembledAt` is when this response was assembled, in Unix milliseconds, not a venue timestamp. The other way to open an event is [Get venue event](/api-reference/markets/get-venue-event) plus [List venue markets](/api-reference/markets/list-venue-markets). On both, `cluster=true` (the default) covers the matched cluster. `cluster=false` scopes counts and the market list to that one venue event. Only the strings `true` and `false` are accepted. The embedded `venueMarkets` array is not the market page. On a list it is a preview of up to three markets and is deprecated. Sibling venues are `matchedVenueMarkets[]`, not extra rows inside the anchor's `venueMarkets[]`. Building that screen is [Building market views](/recipes/building-market-views). ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const view = await client.getEventBootstrap("EVENT_ID"); ``` ```bash cURL theme={null} curl -H "x-app-id: YOUR_APP_ID" \ "https://api.agg.market/venue-events/EVENT_ID/bootstrap" ``` [Get the first useful event view](/api-reference/markets/get-the-first-useful-event-view). ## Charts Chart history is keyed by `venueMarketOutcomeId`. There is no `/charts/candlesticks` route. `GET /charts/history/manifest` returns at most eight authorized chunk URLs, the effective resolution, the finalization boundary, and a delivery state. `GET /charts/bars` is the canonical bar route and the unfinished tail. Both take `venueMarketOutcomeId`, optional `resolution`, optional `from`, required `to` (Unix milliseconds), and optional `countBack` from 1 to 5,000. Wire resolutions are `1`, `5`, `60`, and `1D`. The SDK methods accept `1m`, `5m`, `1h`, and `1d` and map them to those wire values. Use the resolution the manifest returns. It can be coarser than the one you asked for. Render the finalized prefix from the manifest, then request the remainder from `/charts/bars` when the finalization boundary is behind `to`. If the manifest is not ready, use a bounded `/charts/bars` read instead. Signed chunk URLs are temporary. Keep their query strings, do not put them in a public cache, and request a fresh manifest when they expire. `loadImmutableHistory` checks each chunk's SHA-256 before you draw it. `meta.state` is `ready`, `warming`, `unsupported`, `failed`, or `empty`. `ready` still needs a look at completeness: `ready` with `complete: false` means cached bars that are refreshing in the background, so render them and refresh on your normal schedule. `warming` means keep the current view and retry within a budget using `retryAfterMs`. `unsupported` means stop polling that path. `failed` is an error, not an empty chart. `empty` means no bars for the range. An empty success body is not a healthy chart. `semantics` is `venue-ohlcv-v1`, `polymarket-price-samples-v1`, or `kalshi-ohlcv-quote-fallback-v2`. Do not draw sampled prices as venue trade candles, invent volume, or bridge a real gap. Live overlay after that history is [Real-time charts](/recipes/websocket-charts). [Get versioned chart history references](/api-reference/market-data/get-versioned-chart-history-references). [Get chart bars](/api-reference/market-data/get-chart-bars). ## Search `GET /search` requires `q` and `type` (`events` or `markets`). `mode=instant` is the bounded typeahead path. `mode=full` is the submitted search. Omitting `mode` is `full`. Instant and full share one read quota. Debounce input and abort the request you replaced. Page with the returned cursor. Use [List venue events](/api-reference/markets/list-venue-events) for ordinary filtered browsing, not search. `meta.complete` describes whether this search finished its ranking work, not whether more pages exist. Read `hasMore` for pages. A search that misses its budget returns 503, not an empty page you can cache. [Search events or markets](/api-reference/markets/search-events-or-markets). ## Order books Outcome ids and market ids are not interchangeable. Which id to store is [Ids](/concepts/ids). | You have | Read | | - | - | | `venueMarketOutcomeId` | [`GET /orderbooks/outcomes`](/api-reference/market-data/get-bounded-outcome-snapshots) (up to 100 ids) or [`GET /orderbook/outcome/{outcomeId}`](/api-reference/market-data/get-live-orderbook-for-a-single-outcome). The [orderbook socket](/recipes/websocket-orderbook) takes outcome ids too. | | `venueMarketId` | [`GET /orderbooks`](/api-reference/market-data/get-live-orderbooks-for-multiple-markets). Midpoints are [`GET /midpoints`](/recipes/comparing-venue-prices). | The two outcome routes share one quota, charged by distinct outcome id. A single-outcome read costs one unit. Duplicate ids in a batch count once. Switching from the batch route to the single route does not reset that budget. A missing outcome on the batch route is `null`, not a dropped row. A market-id orderbook row carries `status` and `error`. Do not treat an empty book as proof the market is healthy. `snapshotChecksum` covers the depth-limited levels in that response. `checksum` is the full engine book. Do not recompute the full checksum from a truncated REST slice. REST `timestamp` is Unix milliseconds. There is no midpoint WebSocket channel. ## Read quotas Market-data reads have per-group caps on top of the global limits. The current numbers are on [Rate limits](/concepts/rate-limits#endpoint-limits). ## Browser Worker Worker support is optional. `createWorkerWebSocket` is exported from `@agg-build/sdk/browser`. `MarketDataEngine` is exported from `@agg-build/sdk/market-data`. Your app supplies the Worker. The default `@agg-build/sdk` entry does not start one, and it stays usable in Node and React Native. Do not put an API key in the Worker asset. Keep one client and one Worker for the life of the page. If the Worker fails, close it and use the direct socket. # WebSocket Protocol Source: https://docs.agg.market/api/websocket Stream live orderbooks, trades, arbitrage returns, heartbeats, and user events over WebSocket # WebSocket Protocol Connect to the AGG WebSocket API to receive live aggregated orderbook updates, public trades, cross-venue arbitrage-return updates, JSON heartbeat events, and authenticated user events across connected venues. Browsers connect with your app ID; backends connect with an [API key](#server-connections) and can also stream [every user's events](#5-app-events-server-to-server). ```text theme={null} wss://ws.agg.market/ws?appId=YOUR_APP_ID ``` There is no midpoint channel. A `midpoint` field on an orderbook frame is not a subscription. **Wire** The JSON frames on this page are the protocol. **SDK** Do not copy these frames. Use [Real-Time Orderbook](/recipes/websocket-orderbook), [Real-Time Charts](/recipes/websocket-charts), [Real-Time Arbitrage](/recipes/websocket-arb), and [User Notifications](/recipes/websocket-notifications). ## Getting connected **1. Open a connection** Pass your `appId` as a query parameter. ```javascript theme={null} const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`); ``` How the gateway admits the connection depends on where it runs: * **Browser:** the handshake's `Origin` header must match one of your app's [allowed origins](/environments#allowed-origins). Browsers send it automatically. * **Server:** send your API key in the `x-app-api-key` handshake header. No `Origin` is needed. See [Server connections](#server-connections). WebSocket API-key enforcement is currently deferred for all apps, including apps with **Require API key** turned on. Without a key, an allowed `Origin` is still required. Any supplied key must be valid; an invalid key closes with `4003` even if the Origin is allowed. This does not change REST API-key requirements. The `app-events` channel still requires an API key issued for your app. To sign a user in, send their token in the [`authenticate`](#authenticate) message after `connected`. A `token` query parameter still works but is deprecated, because URLs end up in proxy and load-balancer logs. **2. Wait for the connected message** The server acknowledges the connection with app and optional user context: ```json theme={null} { "type": "connected", "appId": "app_demo123", "protocolVersion": 2, "auth": "browser" } ``` `auth` is `browser` (admitted by `Origin`) or `key` (admitted by `x-app-api-key`). `protocolVersion` 2 adds error codes, request ids, event ids and resume. `userId` appears only when a token was passed in the URL. Send subscriptions after this message arrives. **3. Subscribe to outcome streams** Send one or more outcome IDs with a channel to start receiving orderbook or trade updates. Each outcome has its own orderbook — there is no complement derivation. ```json theme={null} { "action": "subscribe", "channel": "orderbook", "outcomeIds": ["vmo_1", "vmo_2"] } ``` `outcomeId` is an outcome's `id` (`outcomes[].id` on REST market responses). Each outcome belongs to one venue. > [!NOTE] > > Keep orderbook and trade subscriptions together at or under **500 per connection**, and arb > market subscriptions at or under 500. Ids beyond the limit are rejected with a > `subscription_limit` error. The other per-connection limits are in [Limits](#limits). ### Server connections Backends authenticate the handshake with an API key from the admin dashboard (**Develop → API**). The key replaces the `Origin` check, and the connection is metered per key rather than per IP, so many sockets from one server are fine. ```typescript SDK (Node 22+) theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", wsUrl: "wss://ws.agg.market/ws", appId: process.env.AGG_APP_ID, apiKey: process.env.AGG_API_KEY, // sent as x-app-api-key, never from a browser }); const ws = client.createWebSocket({ onError: (e) => console.error(e.code, e.message) }); ws.subscribe("vmo_1"); ``` ```typescript ws package theme={null} import WebSocket from "ws"; const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`, { headers: { "x-app-api-key": process.env.AGG_API_KEY }, }); ``` On Node versions without a global `WebSocket`, pass the `ws` package to the SDK: `client.createWebSocket(callbacks, { webSocketImpl: WebSocket })`. > [!WARNING] > > An API key is a secret. Never put it in browser or mobile code. If the key is revoked or > expires, open connections that used it are closed with `4003` within a minute. ## 1. Orderbook stream Subscribe to an outcome's orderbook to receive a full snapshot followed by incremental deltas. Each outcome has its own orderbook — there is no complement derivation. Levels carry venue attribution for the outcome's venue, and the venue's own book is included alongside. ### Subscribe ```json theme={null} { "action": "subscribe", "channel": "orderbook", "outcomeIds": ["vmo_1"] } ``` You receive a confirmation before the initial snapshot: ```json theme={null} { "type": "subscribed", "outcomeIds": ["vmo_1"], "channel": "orderbook" } ``` ### Initial snapshot ```json theme={null} { "type": "orderbook_snapshot", "outcomeId": "vmo_1", "seq": 1710000001, "checksum": 2918476531, "bids": [ [0.55, 800, { "predict": 800 }], [0.54, 400, { "predict": 400 }] ], "asks": [ [0.56, 600, { "predict": 600 }], [0.57, 300, { "predict": 300 }] ], "venueOrderbooks": { "predict": { "bids": [[0.55, 800], [0.54, 400]], "asks": [[0.56, 600], [0.57, 300]] } }, "venues": { "predict": { "bestBid": 0.55, "bestAsk": 0.56 } }, "midpoint": 0.555, "spread": 0.01, "timestamp": 1710000000000 } ``` Orderbook `timestamp` is Unix milliseconds on the wire, and the SDK reports it as seconds. > [!TIP] > > Aggregated levels use the wire format `[price, totalSize, { venue: sizeAtPrice }]`. Per-venue > levels use `[price, size]`. Bids are sorted descending by price, asks ascending. A change with > size `0` removes that level. All `timestamp` values on the wire are Unix milliseconds. If no orderbook is available for an id, you get an `error` whose message starts with `Snapshot unavailable for ` instead of a snapshot. The subscription stays active, so deltas may still arrive: ignore deltas for an outcome you have no book for. Check that the id is an outcome id (`outcomes[].id`), not a market id, then retry with `resnapshot` after a delay rather than in a loop. ### Incremental deltas ```json theme={null} { "type": "orderbook_delta", "outcomeId": "vmo_1", "seq": 1710000002, "checksum": 3847261950, "bidChanges": [ [0.55, 900, { "predict": 900 }] ], "askChanges": [], "venueDeltaBooks": { "predict": { "bidChanges": [[0.55, 900]], "askChanges": [] } }, "venues": { "predict": { "bestBid": 0.55, "bestAsk": 0.56 } }, "midpoint": 0.555, "spread": 0.01, "timestamp": 1710000001000 } ``` ### Market resolved After you subscribe to `orderbook` for an outcome, a resolution or close arrives once as `market_resolved`. The outcome's orderbook and trade subscriptions end with it: nothing further arrives, and you don't need to unsubscribe. Trade-only subscribers are not sent this message. `result` is the label of the first outcome with `winner: true`, or `null` when none is marked winner. `outcomes[]` are the sibling outcomes for that market. ```json theme={null} { "type": "market_resolved", "outcomeId": "vmo_1", "status": "resolved", "result": "Yes", "outcomes": [ { "label": "Yes", "winner": true, "externalIdentifier": "yes-token", "payoutPerShare": 1, "refundAtCost": false }, { "label": "No", "winner": false, "externalIdentifier": "no-token", "payoutPerShare": 0, "refundAtCost": false } ], "timestamp": 1710000000000 } ``` `status` is `resolved` or `closed`. `outcomes` can be empty, and `winner` is omitted when it isn't known. `payoutPerShare` and `refundAtCost` are omitted when the venue did not report them. Settlement meaning is on [Market resolution](/concepts/market-resolution). ### Sequencing and resync Every orderbook message includes a `seq` value for that outcome. `seq` is monotonic within one stream of the book. Your client is responsible for maintaining local book state and detecting integrity issues. **Apply deltas when `delta.seq === book.seq + 1`.** After applying, recompute the checksum (XOR of per-level CRC32 values) and compare it to `checksum` in the message. If they match, the book is consistent. The exact level encoding is in the [market-data guide](https://agg.market/docs/market-data-api.md). **Drop stale deltas** where `delta.seq <= book.seq` — these can arrive after a subscribe when deltas predate the initial snapshot. Silently discard them. **Request a resnapshot** if you detect a forward gap (`delta.seq > book.seq + 1`) or a checksum mismatch: ```json theme={null} { "action": "resnapshot", "channel": "orderbook", "outcomeIds": ["vmo_1"] } ``` **Always replace your book with an `orderbook_snapshot`, even when its `seq` is lower than yours.** If AGG rebuilds an outcome's book, that book starts a new `seq` stream. When the first update from the rebuilt book arrives, the gateway sends you a fresh `orderbook_snapshot` without being asked. Apply the following deltas against the snapshot's `seq`. ### Unsubscribe ```json theme={null} { "action": "unsubscribe", "channel": "orderbook", "outcomeIds": ["vmo_1"] } ``` ### Staleness checks Heartbeats prove the connection is alive, not that each outcome is updating. A quiet market can legitimately go several minutes without a delta, so silence alone is not an error, and silence alone can't tell you a stream has stopped reaching you. **We recommend a 5-minute REST watchdog for every client that keeps books open.** The gateway can only resend a snapshot when it sees a new update, so a quiet book that went wrong stays wrong. The watchdog confirms each book against the server instead of trusting that the stream is still flowing. 1. Once a minute, find subscribed outcomes whose book has not changed for 5 minutes. 2. Fetch up to 100 of them with `GET /orderbooks/outcomes?venueMarketOutcomeIds=id1,id2,…&depth=1` (see [Market-data API](/api/market-data)). Results come back in request order, with `null` for an id that can't be served. 3. Compare each returned `seq` with your book's `seq`: * **Same:** the market is quiet and your book is current. Reset its 5-minute timer. * **Different:** you missed updates. Send `resnapshot` for that outcome. * **Still different on the next check:** `unsubscribe` and `subscribe` the outcome. That resets all per-subscription state on the gateway. 4. Ignore an outcome whose book changed while the request was in flight. Its `seq` moved on for a normal reason. Each outcome in a batch costs one market-data quota unit, so check only silent outcomes. On a `429`, wait for `Retry-After` before the next check. ```javascript theme={null} // `books` and `ws` come from your client; see the raw example below. const SILENCE_MS = 5 * 60_000; const lastChange = new Map(); // outcomeId -> ms; set on every snapshot and applied delta const resnapshotted = new Set(); let inFlight = false; let pausedUntil = 0; setInterval(async () => { const now = Date.now(); if (inFlight || now < pausedUntil) return; const silent = [...books.keys()] .filter((id) => now - (lastChange.get(id) ?? now) >= SILENCE_MS) .slice(0, 100); if (silent.length === 0) return; const asked = new Map(silent.map((id) => [id, lastChange.get(id)])); const url = new URL("https://api.agg.market/orderbooks/outcomes"); url.searchParams.set("venueMarketOutcomeIds", silent.join(",")); url.searchParams.set("depth", "1"); inFlight = true; try { const res = await fetch(url, { headers: { "x-app-id": appId } }); if (res.status === 429) { pausedUntil = Date.now() + Number(res.headers.get("retry-after") ?? 60) * 1000; return; } if (!res.ok) return; const { data } = await res.json(); silent.forEach((id, i) => { const server = data[i]; const book = books.get(id); // Skip ids the server can't serve, and books that changed while we asked. if (!server || !book || lastChange.get(id) !== asked.get(id)) return; if (server.seq === book.seq) { lastChange.set(id, Date.now()); resnapshotted.delete(id); } else if (!resnapshotted.has(id)) { resnapshotted.add(id); lastChange.set(id, Date.now()); // give the snapshot a full window ws.send(JSON.stringify({ action: "resnapshot", channel: "orderbook", outcomeIds: [id] })); } else { resnapshotted.delete(id); ws.send(JSON.stringify({ action: "unsubscribe", channel: "orderbook", outcomeIds: [id] })); ws.send(JSON.stringify({ action: "subscribe", channel: "orderbook", outcomeIds: [id] })); } }); } finally { inFlight = false; } }, 60_000); ``` ### Best practices **Connection** * Wait for `connected` before you subscribe. Only the first 20 messages sent before it are kept; the rest are dropped. * Answer protocol pings. Browsers and most WebSocket libraries do this automatically; a connection that misses two pings in a row is closed. * Treat 45 seconds without any frame, heartbeats included, as a dead connection and reconnect. Heartbeats arrive every 30 seconds. * Reconnect with exponential backoff and jitter. After reconnecting, discard your books, subscribe again to everything, and rebuild from the new snapshots. Subscriptions don't survive a new connection. * Don't keep reconnecting on `4001` or `4003`. Those mean a missing app id, an inactive or unknown app, a disallowed origin, a missing or revoked API key, or a non-user token, and retrying won't help until the configuration is fixed. **Orderbooks** * Apply a delta only when its `seq` is exactly one more than your book's, and verify its `checksum`. * On a forward `seq` gap or a checksum mismatch, send `resnapshot`. Never keep patching a book you can't verify. * Replace your book on every `orderbook_snapshot`, whatever its `seq`. * Run the [5-minute REST watchdog](#staleness-checks). * On `market_resolved`, drop that outcome's book. **Throughput** * Subscribe only to outcomes you are displaying or trading, and unsubscribe when you stop. * Keep your message handler fast and move heavy work off it. If your client falls more than about 1 MB behind, the gateway skips frames for it. On orderbooks this shows up as a `seq` gap, which a resnapshot repairs. If it stays behind for 10 seconds, the connection is closed with `4008`. Reconnect and resume to recover user events. Skipped trades, heartbeats, arbitrage updates and `market_resolved` messages are not resent. **Authentication** * Use the `authenticate` message. The `token` URL parameter is deprecated. * On `auth_expiring`, refresh the token and send `authenticate` again. After any reconnect, authenticate with `resumeFrom` set to your last `eventId`. * If `resumed` arrives with `complete: false`, reload orders, balances and withdrawals over REST. * Backends: authenticate the handshake with `x-app-api-key` instead of setting an `Origin`. Send the key only from your backend, never from a browser. > [!TIP] > > The `@agg-build/sdk` handles the orderbook practices for you: seq tracking, stale delta > filtering, checksum validation, resnapshots, server-sent snapshots and the 5-minute watchdog. It > also reconnects with jittered backoff after 45 seconds of silence, sends your token by message, > resumes user events from the last `eventId`, and refreshes the token on `auth_expiring`. Use > `client.createWebSocket()` (the hooks use it) or the `useLiveMarket` hook. See > [Real-Time Orderbook](/recipes/websocket-orderbook#integrity) for the watchdog settings. SDK, hooks, and UI orderbooks are on [Real-Time Orderbook](/recipes/websocket-orderbook). That page does not repeat these frames. ## 2. Trade stream Subscribe to the public trade feed for an outcome. No user auth is required. ### Subscribe ```json theme={null} { "action": "subscribe", "channel": "trades", "outcomeIds": ["vmo_1"] } ``` Confirmation: ```json theme={null} { "type": "subscribed", "outcomeIds": ["vmo_1"], "channel": "trades" } ``` ### Trade event ```json theme={null} { "type": "trade", "outcomeId": "vmo_1", "venue": "hyperliquid", "side": "buy", "price": 0.55, "size": 100, "timestamp": 1710000000000, "tradeId": "915328169553830" } ``` `tradeId` is the venue's own id for the fill, present when the venue supplies one (Hyperliquid today). Use it to deduplicate. Two fills can share side, price, size and millisecond timestamp, so those fields alone cannot identify a trade. ### Unsubscribe ```json theme={null} { "action": "unsubscribe", "channel": "trades", "outcomeIds": ["vmo_1"] } ``` For chart-building patterns on top of orderbook and trade data, see [Real-Time Charts](/recipes/websocket-charts). ## 3. Authenticated user events Receive user-specific events, such as order confirmations and balance updates, after the connection has user-level auth. ### Authenticate Send the user's access token after `connected`: ```json theme={null} { "action": "authenticate", "token": "eyJhbGciOiJIUzI1NiIs..." } ``` ```json theme={null} { "type": "authenticated", "userId": "usr_xyz789", "expiresAt": 1710000900000 } ``` `expiresAt` is when the token expires (Unix ms). Send `authenticate` again with a refreshed token at any time. That re-authenticates without opening a second socket, and it also switches users. Suspended users are refused. A user can hold at most 20 authenticated sockets at once. ### Session lifetime A user session on the socket lasts only as long as its token: * About a minute before expiry you receive `auth_expiring`. Refresh the token and send `authenticate` again. * At expiry, or within a minute of the user signing out elsewhere or being suspended, you receive `auth_expired`. The connection stays open at app level: market data keeps flowing, but user events stop until you authenticate again. ```json theme={null} { "type": "auth_expiring", "userId": "usr_xyz789", "expiresAt": 1710000900000 } ``` ```json theme={null} { "type": "auth_expired", "userId": "usr_xyz789", "reason": "expired" } ``` `reason` is `expired`, `revoked` (signed out or token version bumped) or `suspended`. Clients created with `AggClient.createWebSocket()` refresh and re-authenticate for you. ### Event ids and resume Every user event carries an `eventId`, along with `stream: "user"` and the `userId`. Ids increase within the stream. Keep the last one you processed. When you reconnect, pass it as `resumeFrom`: ```json theme={null} { "action": "authenticate", "token": "eyJhbGciOiJIUzI1NiIs...", "resumeFrom": "1710000000123-0" } ``` The gateway replays the events after that id, marked `"replayed": true`, before live events continue. Then it sends: ```json theme={null} { "type": "resumed", "stream": "user", "fromEventId": "1710000000123-0", "replayed": 3, "complete": true } ``` * The replay starts slightly before your cursor and may resend events you already have. De-duplicate by `eventId`. * `complete: false` means some events may be missing. This happens when the cursor is older than 24 hours, more than the last 500 events have passed, or the event log was unavailable. In that case, reload orders, balances and withdrawals over REST. * An event with no `eventId` was delivered while the event log was unavailable, and a resume can't replay it. The SDK tracks the cursor, resumes and de-duplicates automatically. Resume covers reconnects. [Webhooks](/recipes/webhooks/overview) remain the durable record for your backend. ### Order submitted ```json theme={null} { "type": "order_submitted", "userId": "usr_xyz789", "venue": "predict", "orderId": "ord_abc123", "side": "buy", "price": 0.55, "size": 100, "outcomeId": "vmo_1", "timestamp": 1710000000000, "eventId": "1710000000123-0", "stream": "user" } ``` ### Balance update ```json theme={null} { "type": "balance_update", "venue": "predict", "tradingBalanceCents": 94500, "walletBalanceCents": 100000, "timestamp": 1710000000000 } ``` ### Order event Venue-agnostic stream of execution-plan lifecycle and terminal order status events. Emitted for every active venue (Polymarket, Kalshi, Predict, …) automatically — no per-venue handling on the client. Discriminate on the `event` field. #### Step lifecycle (progress UI) ```json theme={null} { "type": "order_event", "event": "step_started", "userId": "usr_xyz789", "orderId": "ord_abc123", "dagRunId": "dag_mnp...", "stepId": "execute-...", "stepType": "submit-order", "sequence": 3, "totalSteps": 7, "venue": "polymarket", "timestamp": 1710000000000 } ``` `event` values: `step_started` | `step_completed` | `step_waiting` | `step_failed` | `step_retrying`. `sequence` is the 1-based declaration index of the step within the execution plan, and `totalSteps` is the total step count. Pair them to render progress like "Step 3 of 7". `dag_started` also carries `totalSteps` so the UI can show an empty progress bar before the first step runs. For branching execution plans, `sequence` reflects declaration order, not strict execution order — parallel branches may emit non-monotonic sequence values. #### Terminal fill (success toast + position refresh) ```json theme={null} { "type": "order_event", "event": "filled", "userId": "usr_xyz789", "orderId": "ord_abc123", "venue": "polymarket", "filledAmountRaw": "1000000", "remainingAmountRaw": "0", "timestamp": 1710000000000 } ``` `event` values: `filled` | `partial_fill`. `remainingAmountRaw` is present only on `partial_fill`. #### Failure (error toast) ```json theme={null} { "type": "order_event", "event": "failed", "userId": "usr_xyz789", "orderId": "ord_abc123", "venue": "polymarket", "errorReason": "Polymarket CLOB error (400): ...", "timestamp": 1710000000000 } ``` `event` values: `failed` (order-level) | `dag_failed` (execution-plan-level). #### Execution plan lifecycle (optional) ```json theme={null} { "type": "order_event", "event": "dag_started", "userId": "usr_xyz789", "orderId": "ord_abc123", "dagRunId": "dag_mnp...", "templateName": "route-execution", "totalSteps": 7, "timestamp": 1710000000000 } ``` `event` values: `dag_started` | `dag_completed` | `dag_failed` | `dag_cancelled`. > [!NOTE] > > All `order_event` messages are broadcast verbatim — the gateway does not filter by event type. Switch on `event` and ignore types your UI doesn't care about. `redeem_event` reports EVM redemption completion. See [Market resolution](/concepts/market-resolution) for how to track claims and update positions. For a notification-focused recipe, see [User Notifications](/recipes/websocket-notifications). That page does not repeat these frames. ## 4. Arbitrage streams Stream the live cross-venue **arbitrage return** (`arbReturn`) for matched markets — the estimated return, as a decimal fraction (e.g. `0.012` = 1.2%), from the best prices across the venues in a matched cluster. This is the same value returned by the [List Venue Events API](/api-reference/markets/list-venue-events); the stream keeps it live. There are two ways to consume it: a **per-market** channel for a focused view (one or a few markets), and a single coalesced **feed** channel for a whole listing. > [!NOTE] > > Arbitrage streams are **delta-only** — there is no snapshot or sequence number. Load the initial > `arbReturn` from a REST listing, then apply stream updates on top. Updates are last-value-wins > and loss-tolerant: a dropped message self-heals on the next update. Subscribing requires only an > app-level connection (your `appId`) — `arbReturn` is not user-scoped. For REST request and > response details, use the [API Reference](/api-reference/markets/list-venue-events). ### Per-market channel Subscribe with one or more `marketId`s (a market's `id`) to receive an update whenever that market's arbitrage return moves. ```json theme={null} { "action": "subscribe", "channel": "arb", "marketIds": ["vm_1", "vm_2"] } ``` Confirmation. The ack field `outcomeIds` contains the `marketIds` you subscribed: ```json theme={null} { "type": "subscribed", "outcomeIds": ["vm_1", "vm_2"], "channel": "arb" } ``` Update message — published per market as the value changes: ```json theme={null} { "type": "arb_market_update", "marketId": "vm_1", "activeVenuesOnly": true, "venueEventId": "ve_1", "arbReturn": 0.012, "ts": 1710000000000 } ``` `activeVenuesOnly` is always `true`: the value only reflects active venues. `venueEventId` is the parent event of the market (or `null`), so you can roll updates up to an event-level value (e.g. the max across an event's markets). Unsubscribe: ```json theme={null} { "action": "unsubscribe", "channel": "arb", "marketIds": ["vm_1"] } ``` ### Feed channel For a listing of many markets, subscribe once to the coalesced feed instead of managing a subscription per visible market. No ids are sent. ```json theme={null} { "action": "subscribe", "channel": "arb-feed" } ``` Confirmation: ```json theme={null} { "type": "subscribed", "channel": "arb-feed" } ``` Batch message — emitted on a short coalescing interval, carrying only the markets whose `arbReturn` changed since the last flush (deduped to the latest value per market): ```json theme={null} { "type": "arb_feed_batch", "feed": "arb", "activeVenuesOnly": true, "entries": [ { "marketId": "vm_1", "venueEventId": "ve_1", "arbReturn": 0.012, "ts": 1710000000000 }, { "marketId": "vm_2", "venueEventId": "ve_1", "arbReturn": 0.004, "ts": 1710000000000 } ], "flushTs": 1710000000300, "chunk": 0, "chunkCount": 1 } ``` > [!TIP] > > A single flush is split into multiple messages when it exceeds the per-batch entry cap — `chunk` > is the 0-based index and `chunkCount` the total for that flush. Merge entries into your local map > by `marketId` regardless of chunk; the feed is a stream of deltas, not a full snapshot each flush. Unsubscribe: ```json theme={null} { "action": "unsubscribe", "channel": "arb-feed" } ``` > [!TIP] > > The `@agg-build/sdk` exposes `subscribeArb(marketId, cb)` and `subscribeArbFeed(cb)`, and the > `@agg-build/hooks` package adds `useMarketArb` and `useArbFeed` (which maintains a live > `byMarket` map plus an event-level `byEvent` max rollup). See > [Real-Time Arbitrage](/recipes/websocket-arb). ## 5. App events (server-to-server) A backend connected with an API key can receive every user event of its app on one socket. These are the same frames as in [section 3](#3-authenticated-user-events): order submitted, balance update, order, redeem and withdrawal events. Each one carries its `userId`. ```json theme={null} { "action": "subscribe", "channel": "app-events" } ``` ```json theme={null} { "type": "subscribed", "channel": "app-events" } ``` ```json theme={null} { "type": "order_event", "event": "filled", "userId": "usr_xyz789", "orderId": "ord_abc123", "venue": "polymarket", "filledAmountRaw": "1000000", "timestamp": 1710000000000, "eventId": "1710000000456-0", "stream": "app" } ``` * The channel needs an `x-app-api-key` issued for **this** app on the handshake. Browser connections, and keys from another app, get `app_events_forbidden`. * `eventId` here is the app stream's id, not the same as the user stream's id for the same event. Resume with `{ "action": "subscribe", "channel": "app-events", "resumeFrom": "" }`. The app stream keeps the last 10,000 events or 24 hours, whichever ends first. The `resumed` frame works as in [Event ids and resume](#event-ids-and-resume). * Unsubscribe with `{ "action": "unsubscribe", "channel": "app-events" }`. ```typescript SDK theme={null} const ws = client.createWebSocket(); // client created with apiKey, server-side const stop = ws.subscribeAppEvents((event) => { // Raw frames: timestamps are milliseconds. Resumes across reconnects automatically. if (event.type === "order_event" && event.event === "filled") onFill(event.userId, event); }); ``` > [!NOTE] > > App events are a fast path, not a ledger. Use [webhooks](/recipes/webhooks/overview) for > delivery you must not miss, and reconcile over REST when a `resumed` frame says > `complete: false`. ## Connection management ### Heartbeat The service emits a JSON heartbeat event that clients can observe directly: ```json theme={null} { "type": "heartbeat", "ts": 1710000000000 } ``` The gateway sends this event every 30 seconds, along with a protocol-level ping. Browsers don't expose pings to JavaScript, so use the JSON `heartbeat` (or any frame) as your liveness signal: if nothing arrives for 45 seconds, reconnect. A connection that doesn't answer two pings in a row is closed by the server. ### Reconnection If the connection closes, reconnect with exponential backoff and jitter. Once the new connection sends `connected`, discard your books, subscribe again to everything, and authenticate with `resumeFrom` set to your last `eventId`: ```javascript theme={null} function connect(attempt = 0) { const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`); ws.onclose = (event) => { if (event.code === 4001 || event.code === 4003) return; // configuration error: don't retry // 4008 (slow consumer) and 4029 (rate limited) are retryable with backoff. const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000); setTimeout(() => connect(attempt + 1), delay); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "connected") { attempt = 0; clearLocalBooks(); resubscribeAll(ws); ws.send(JSON.stringify({ action: "authenticate", token, resumeFrom: lastEventId })); } // ...handle other messages }; } ``` ### Error handling Protocol or validation errors arrive as JSON messages: ```json theme={null} { "type": "error", "code": "subscription_limit", "message": "Rejected outcomes: vmo_9. Subscription limit reached (max 500)", "outcomeIds": ["vmo_9"], "id": "req-42" } ``` Branch on `code`, not on `message`. `outcomeIds` (or `marketIds` for arb) lists the ids the error applies to. `id` echoes the `id` you put on the request. Any client frame can carry an `id` (string or number), and acks such as `subscribed` and `authenticated` echo it too. | `code` | Meaning | | - | - | | `missing_app_id`, `invalid_app` | No `appId`, or an unknown or inactive app | | `origin_not_allowed` | Browser `Origin` not in the app's allowed origins | | `api_key_required` | Reserved; not currently emitted while WebSocket API-key enforcement is deferred | | `invalid_api_key` | The key is malformed, unknown, revoked or expired | | `invalid_token`, `token_app_mismatch`, `admin_token_not_allowed` | The user token was refused. The connection stays at its previous level | | `too_many_connections` | A per-IP, per-key or per-user connection limit was reached | | `rate_limited` | A message or snapshot rate limit was reached. The frame or ids were dropped; retry shortly | | `invalid_message`, `too_many_ids` | Malformed frame, or more than 1,000 ids in one frame | | `subscription_limit` | Ids beyond the per-connection subscription limit | | `outcomes_blocked` | Outcomes or markets disabled for your app | | `not_subscribed` | `resnapshot` for an outcome this connection isn't subscribed to | | `snapshot_unavailable` | No orderbook for that id. Orderbooks take outcome ids, not market ids | | `app_events_forbidden` | `app-events` without an API key for this app | | `internal_error` | Server-side failure admitting the connection or processing a frame | An error about a request, such as a rejected subscription, leaves the connection open. An invalid token also leaves it open: the connection continues at app level. Connection-level failures send an `error` and then close with one of these codes: | Code | Meaning | Reconnect? | | - | - | - | | `4001` | Missing `appId` query parameter | No. Fix the URL | | `4003` | Inactive or unknown app, `Origin` not allowed, a supplied API key invalid or revoked, or a non-user (admin) token in the URL. Also sent to open connections when the app is deactivated, its origins change, or the key is revoked | No. Fix the configuration | | `4008` | Slow consumer: the client stayed more than 1 MB behind for 10 seconds | Yes, with backoff. Process frames faster or subscribe to less | | `4029` | Too many connection attempts or connections, or too many pending messages | Yes, with backoff | | `1009` | A client frame larger than 64 KB | Fix the client | | `1001` | Server restarting | Yes, with backoff | | `1006` | Connection dropped, including after missed pings | Yes, with backoff | | `1013` | Temporary connection-admission failure, such as an unavailable app lookup | Yes, with backoff | ### Stream interruptions If the gateway loses its upstream feed briefly, every affected connection receives: ```json theme={null} { "type": "stream_interrupted", "channels": ["orderbook", "trades"], "timestamp": 1710000000000 } ``` Orderbooks are resnapshotted automatically, so expect fresh `orderbook_snapshot` frames to follow. Trades and arb updates published during the gap are not resent. User events are not affected, because they don't use this feed. ### Limits Limits apply per connection unless noted otherwise. | Limit | Value | When exceeded | | - | - | - | | Client frame size | 64 KB | Connection closed with `1009` | | Ids per frame | 1,000 | `too_many_ids`; the frame is ignored | | Orderbook + trade subscriptions | 500 | `subscription_limit` | | Arb market subscriptions | 500 | `subscription_limit` | | Client frames | 20 per second, bursts of 200 | `rate_limited`; extra frames are dropped | | Orderbook snapshots (subscribe + `resnapshot`) | 100 per second, bursts of 1,000 | `rate_limited` for the ids over the budget | | Browser connections per IP | 250 | `too_many_connections`, close `4029` | | Connections per API key | 200 | `too_many_connections`, close `4029` | | New connections per IP | 300 per minute | Close `4029` | | Authenticated sockets per user | 20 | `too_many_connections`; the socket stays at app level | | Send backlog | 1 MB | Frames are skipped. After 10 seconds the connection is closed with `4008` | `resnapshot` only works for outcomes the connection is subscribed to, and a resnapshot already in flight for an outcome absorbs repeat requests for it. ## Related > **[Setup Guide](/quickstart/react)** > > Provider wiring, auth setup, and SDK client initialization. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > SDK, hooks, and UI orderbook implementations. > **[Real-Time Charts](/recipes/websocket-charts)** > > Build live candles from REST history plus WebSocket events. > **[Real-Time Arbitrage](/recipes/websocket-arb)** > > Live cross-venue arbitrage returns via per-market and feed channels. > **[User Notifications](/recipes/websocket-notifications)** > > Authenticated order and balance events with refresh-aware reconnect logic. # Changelog Source: https://docs.agg.market/changelog What shipped across the AGG platform and the @agg-build packages. > [!NOTE] > > Weekly entries cover everything that shipped across the platform, including > API and data-pipeline work. Version entries are the published > `@agg-build` packages — those four are version-linked, so a release usually > bumps them together, and the tags show which ones shipped. > [Subscribe via RSS](/changelog/rss.xml). **2026-10-27 · v4.31.0** * Add `redeemSelfCustody` to redeem a resolved position held in a self-custody wallet. Limitless, Predict and Myriad prompt a transaction (the user pays gas); Polymarket prompts one typed-data signature. `fillSelfCustody` and `redeemSelfCustody` accept a per-call `signer`. Position legs carry `signerAddress` for self-custody holdings, and `computeClosedPositionTotals` returns `signerAddressByOutcome`. `useRedeem().mutateAsync` takes that map and redeems those outcomes from the wallet signer, one prompt at a time, and the rest through `client.redeem`. The Claim winnings button uses it, so self-custody winners can be claimed in the UI. * **sdk:** Add `finalizedAt` to `fees.actual.appFee` on orders: the time the app fee stopped changing (collected in full, skipped, or no longer retried). It is absent, never `null`, while the fee may still change, so a `$0` `under_collected` without it is still being retried. * **sdk, hooks:** Self-custody Hyperliquid buys funded from the deposit wallet now sign their app-fee pre-sign. `fillSelfCustody` and `useSelfCustodySigning` held that pre-sign until a Polymarket order signature (`poly-sign-`) was submitted, so on a Hyperliquid order (`hl-sign-`) it was never signed. The fee then fell to a prompt after the fill, which often went unsigned. The new `isOrderSignatureStep` export recognises both order steps; `isPolymarketOrderSignatureStep` is unchanged. * **sdk, hooks:** Deprecate `includeDirectVenueMarkets` on `listRecurringCryptoMarkets`. The API now ignores it: every recurring crypto window is listed from AGG's own market data before it opens, so the response is the same with or without it. The SDK still accepts and sends the option, and the recurring-markets hooks no longer set it. * **hooks:** `useMarketChart` keeps retrying a warming chart at the server's retry hint for up to about 30 seconds instead of giving up after 8 attempts, so a chart whose history is still filling renders as soon as the data lands instead of staying empty until the next refetch. * **hooks, ui:** Sports pages paint the first market page while later pages load, and price only the section on screen. Order books mount the rows around the spread. The first midpoint fetch for a visible set asks for every market that still has no price, and later polls ask only for cold books. Scrolling retries a midpoint request that was still in flight. The chart summary waits to load the deferred event description so that request does not compete with the first market and order-book reads. * **hooks, ui:** Selling a self-custody position from the positions table now works. The quick-sell button quotes against the wallet that holds the position and fills through `fillSelfCustody` with the `AggWalletSignerProvider` signer. Before, it always sent a managed sell and got `insufficient_position`. A row whose legs sit with different holders hides quick sell; sell it from the market page. Adds `signerFor(walletSigner, address)` to `@agg-build/hooks`, which connects the wallet if needed and returns the signer to pass as the SDK's per-call `signer`. **2026-10-24 · v4.30.0** * Add `redeemSelfCustody` to redeem a resolved position held in a self-custody wallet. Limitless, Predict and Myriad prompt a transaction (the user pays gas); Polymarket prompts one typed-data signature. `fillSelfCustody` and `redeemSelfCustody` accept a per-call `signer`. Position legs carry `signerAddress` for self-custody holdings, and `computeClosedPositionTotals` returns `signerAddressByOutcome`. `useRedeem().mutateAsync` takes that map and redeems those outcomes from the wallet signer, one prompt at a time, and the rest through `client.redeem`. The Claim winnings button uses it, so self-custody winners can be claimed in the UI. * **sdk:** Add `finalizedAt` to `fees.actual.appFee` on orders: the time the app fee stopped changing (collected in full, skipped, or no longer retried). It is absent, never `null`, while the fee may still change, so a `$0` `under_collected` without it is still being retried. * **hooks, ui:** Selling a self-custody position from the positions table now works. The quick-sell button quotes against the wallet that holds the position and fills through `fillSelfCustody` with the `AggWalletSignerProvider` signer. Before, it always sent a managed sell and got `insufficient_position`. A row whose legs sit with different holders hides quick sell; sell it from the market page. Adds `signerFor(walletSigner, address)` to `@agg-build/hooks`, which connects the wallet if needed and returns the signer to pass as the SDK's per-call `signer`. **2026-10-23 · v4.29.1** * Deprecate `includeDirectVenueMarkets` on `listRecurringCryptoMarkets`. The API now ignores it: every recurring crypto window is listed from AGG's own market data before it opens, so the response is the same with or without it. The SDK still accepts and sends the option, and the recurring-markets hooks no longer set it. * **hooks:** `useMarketChart` keeps retrying a warming chart at the server's retry hint for up to about 30 seconds instead of giving up after 8 attempts, so a chart whose history is still filling renders as soon as the data lands instead of staying empty until the next refetch. **2026-10-22 · v4.29.0** * `@agg-build/sdk` exports the self-custody signing policy `fillSelfCustody` uses (`orderSignatureRound`, `signatureTimeoutMs`, `userSignerAddresses`, `isPostFillFeeRequest`, `areOrdersFilled`, `isOptionalFeeRequest`, `isPolymarketOrderSignatureStep`, `isSameWalletAddress`, `withTimeout`). `useSelfCustodySigning` now follows it too: it takes the fill's `signingAddress`, so an optional request the user's own wallet signs (a referral code) waits for the user instead of being dropped after 15 seconds, and with `ordersFilled` a declined post-fill sell fee no longer reports a failed fill. `PlaceOrder` passes both. `@agg-build/hooks` also exports `isWalletUserRejection(error)`, the one check for a user declining a wallet prompt (EIP-1193 code 4001 or the usual wallet copy); `@agg-build/auth` and `PlaceOrder` use it. The open-order Cancel button is disabled, with the reason as its tooltip, on a Hyperliquid order placed from the user's own wallet, which can't be cancelled from this UI yet. * Self-custody trading UI (off by default: `tradingCustody` defaults to `"managed"`). The SDK types `fundingAddresses`, smart-route `signingAddress`/`fundingAddresses` and `custodyWarnings`, and `disconnectAccount(provider, { providerAccountId })`. Hooks add `features.tradingCustody` (`"managed"` default, `"both"`, or `"self-custody"`; any other value is `"managed"`) with `getTradingCustodyMode` / `useTradingCustodyMode`, `features.enableMultiWalletAccountLink`, `AggWalletSignerProvider`, `useSelfCustodySigning`, `useLinkedWallets`, and a `@agg-build/hooks/self-custody` entry with a wagmi + Solana signer. The UI adds a "Trade from" selector and "Pay from" list to PlaceOrder, a wallet line in the order progress view, a wallet-only `"self-custody"` mode (no Agg balance option; a "Link an Ethereum wallet to trade" prompt when none is linked; Agg balance, Deposit and Withdraw hidden in the connect button and profile page), and wallet Disconnect plus "ETH wallet" / "SOL wallet" Connect rows in the Profile modal (mirrored as Link rows in the Trade from menu); `requestLinkWalletOpen(chain)` asks for a wallet on that chain. Auth adds `WalletLinkBridge`, which links the wallet through the same SIWE/SIWS flow as sign-in (auth methods accept `mode: "link"`). SIWS now connects MetaMask's Solana wallet, which reports its account after connecting. Open-order rows now expose `signerAddress`, and PlaceOrder signs cancels of wallet orders with that wallet. Wallet fills use `approveMode: "user_broadcast"`. `useSelfCustodySigning` skips a declined or unanswerable optional signature instead of failing the trade and holds an optional fee pre-sign until the Polymarket order signature is in, PlaceOrder keeps answering post-fill wallet requests (the exact sell fee) until the run is terminal, and `useExecutionProgress` returns `isTerminal`. * **sdk:** WebSocket hardening for trading and server-side use: * Server-side clients authenticate the WebSocket handshake with their API key: `createAggClient({ apiKey, wsUrl })` on Node 22+ sends `x-app-api-key`, so backends no longer set an `Origin`. `AggWebSocket` accepts `webSocketImpl` and `headers` for other runtimes. The key is never sent from a browser. * User events carry an `eventId`. After a reconnect the SDK resumes from the last one, so fills, balance and withdrawal updates sent while the connection was down are replayed, with duplicates dropped. New `onResumed` callback: `complete: false` means reconcile over REST. * New `subscribeAppEvents()` streams every user event of the app to an API-key connection. * New `onAuthExpiring` / `onAuthExpired` callbacks. Clients created by `AggClient` refresh the token when the gateway warns, and re-authenticate the socket. * New `onStreamInterrupted` callback, plus `connection_rejected` and `stream_interrupted` diagnostics. * A socket held only for user events now reconnects after a drop. Reconnect backoff is jittered. Configuration errors (close codes 4001 and 4003) are no longer retried until `connect()` is called. Id lists are split into frames of at most 500. * `WsError` carries a machine-readable `code` (`WsErrorCode`) and the affected `outcomeIds`. * **sdk:** Declare `@sinclair/typebox` as a dependency. Published SDK declarations now import `Static` from TypeBox instead of bundling those types. * **sdk:** Self-custody fills (signingAddress) now execute Limitless market orders; the wallet signs the order, a one-time registration, and broadcasts missing Limitless approvals on Base. A wallet that already has its own Limitless account (made on limitless.exchange or through another app), or whose Limitless account is in smart-wallet trading mode, cannot be traded through a partner app. Self-custody quotes now route such wallets away from Limitless with a `self_custody_limitless_foreign_profile` or `self_custody_limitless_smart_wallet` warning, and fills refuse them with `quote_self_custody_limitless_foreign_profile` / `quote_self_custody_limitless_smart_wallet` before any funds move. * **sdk:** Add `priceChange24h` to venue market outcomes: the outcome's price change over the last 24 hours in absolute probability points (`0.05` = up 5 cents). `null` means unknown, never zero. * **sdk:** Self-custody fills (signingAddress) now execute Predict and Myriad market orders. Predict asks the wallet for a `personal_sign` auth message and the EIP-712 order, and has the wallet broadcast missing USDT and conditional-token approvals on BNB; its buy minimum is \$1 notional. Myriad asks for the EIP-712 order and has the wallet broadcast missing USD1 and conditional-token approvals on BNB. A wallet that is EIP-7702 delegated on BNB is asked to sign a revocation of that delegation before the order is posted. Both venues are market fills only: self-custody limit orders are refused and positions not created through AGG are not offered for sale. * **sdk:** `fillSelfCustody` no longer throws when the user declines the exact post-fill sell-fee signature: the orders have already filled, so it returns the filled result and the fee stays uncollected. * **sdk:** Docs: the self-custody recipe gains a Solana section. Link a Solana wallet with `provider: siws`, name it in `fundingAddresses` (EVM hex or base58 entries are both accepted), and handle the `solana_transaction` signature request in the signer: its payload carries `messageBase64` / `transactionBase64`; sign the unmodified message and return the hex-encoded 64-byte ed25519 signature. AGG co-signs as fee payer, so the wallet needs no SOL. Novig and ProphetX clearinghouse self-custody orders are documented as not yet enabled (their `direct` backend is unaffected). No SDK code changed. * **sdk:** Self-custody fills (signingAddress) can now include Kalshi legs and Novig / ProphetX legs on the app's `direct` backend. These legs trade on the user's own connected account and cash, so they ask the wallet for no signature, and a route that mixes them with wallet-signed venues no longer fails to start. Kalshi needs the user's stored Kalshi API key; without it the fill is refused with `quote_insufficient_balance`. Direct Novig / ProphetX settlement is credited to the account that placed the order, so a user holding both a managed and a self-custody position on the same outcome is paid once. Self-custody limit orders, and sells of positions AGG did not create, stay refused for these venues. * **sdk:** Self-custody fills (signingAddress) now execute TipRun market orders, buys and sells, funded from the wallet's Arbitrum USDC (bridged in when needed). A buy deposits only the shortfall into the wallet's TipRun account: cash already in that account is used first. TipRun account cash cannot be allocated as a separate funding source. The wallet signs an EIP-712 `RegisterAccount` only when it has no TipRun account yet (an existing one is adopted), an EIP-712 `Permit` for each buy that deposits, and the EIP-712 `LimitOrder`. TipRun self-custody limit orders and withdrawals are not supported; withdraw in TipRun's own app. A wallet that owns more than one TipRun account cannot be traded: self-custody quotes route it away from TipRun with a `self_custody_tiprun_ambiguous_account` warning, and fills refuse it with `quote_self_custody_tiprun_ambiguous_account` before any funds move. * **sdk, hooks:** The signed-in trade socket stays open through quoting, so a fill is not missed while the browser is still connecting. Sending an order reuses that socket and only confirms the user stream when that confirmation has not already been sent. * **sdk:** `WsTrade` frames carry an optional `tradeId`, the venue's own id for the fill (Hyperliquid today), so clients can drop duplicate trades reliably. * **hooks, ui:** Self-custody Hyperliquid trades now work from browser wallets such as MetaMask. Hyperliquid order signatures use chainId 1337, which browser wallets refuse to sign. Before the first Hyperliquid fill or limit order from a wallet, the wallet signs a one-time Hyperliquid `approveAgent` on its active chain. That creates an agent key named `agg`, valid for 90 days and stored in the browser's localStorage. The agent key then signs Hyperliquid orders locally, with no wallet prompt. It can trade the account but cannot move funds. `useWagmiSolanaWalletSigner` takes a new `hyperliquidNetwork` option (`"mainnet"` by default), and `AggWalletSigner` gains optional `canSignAs` and `ensureHyperliquidAgent` members. The trade's execution line shows "Enable trading on Hyperliquid (one-time signature)" while the approval is open. The API uses the agent only when `HL_AGENT_SIGNING_ENABLED` is on. * **hooks:** Chart history retries for a range the server reported as warming go straight to `/charts/bars` instead of re-reading the history manifest first, saving one round-trip before a cold chart range appears. * **hooks, ui:** Event and market views keep a single orderbook socket for each visible market. `useMarketOrderbook({ live: false })` reads a parent-owned book and still REST-seeds it, without opening another subscription. Homepage event cards compare only their own midpoint rows. * **hooks:** Keep execution status reconciliation running when polling inputs change during an outstanding request, so completed trades can reach their final UI state after missed WebSocket events. * **hooks:** The demo starts the default homepage event list from its inline preload. `useVenueEvents` uses that response when the query string matches, and sends the normal request for every other query. * **hooks:** Preserve market traversal order when batching midpoint requests while keeping canonical query cache keys. * **hooks, ui:** Use order WebSocket frames as the fast path for Place Order confirmation, and keep status polling as the reconcile path. A submitted order's confirmation now survives navigation: Place Order restores it when its event is opened again (including after a reload in the same tab), and orders left mid-fill are polled in the background so their fill or failure toast still arrives. * **hooks, ui:** Open positions have a Sell button. It sells the position, and a toast reports when the order fills or when the sell cannot be submitted. * **hooks, ui:** Avoid fetching child categories for unrelated homepage tabs, share top-level category requests, and cache category results for five minutes across navigation, including empty responses. * **ui:** Probability charts fit the value axis to the data, draw evenly spaced lines that hold the last price, label each line's end with its name and price, and mark every line under the crosshair while dimming the time after it. `LineChartSeries` gains an optional `endLabel`. The event chart opens on the lines a reader expects first: the Yes side of each market, and the moneyline on sports events. No sides, spreads, totals and further markets stay listed and turn on from their pills; up to four markets are listed. Live lines end on the live price, every event chart opens on ALL, and ProphetX and Novig (no price history) no longer appear in charts. * **ui:** Left-align the BETDEX balance line under the order balance so it lines up with the "Trade from" balance row. * **ui:** Line charts start at the beginning of the selected range: before a market's first price the line is drawn flat at that price, as Kalshi does, instead of leaving the start of the chart empty. * **ui:** Collapsed event cards request live prices only for the two outcome rows that are visible, plus those markets' matched siblings. Scrolling that card loads prices for the remaining embedded markets. * **ui:** Event chart lines follow the API's market order (by chance on multi-option events) instead of re-ranking by volume. * **ui:** Event detail charts open on the 24H (1D) range instead of ALL. Resolved events still open on ALL, since their last 24 hours have nothing to show. * **ui:** The event chart picks its markets by the page's live prices instead of the API's stored order, ranking once per venue so lines do not swap as prices move. `EventMarketsChart` takes an optional `prices` map of each outcome's own venue price, or `null` while those load (no lines are drawn until then). The chart's hover tooltip, shown when more than four lines are on, now follows the crosshair instead of sitting fixed in the corner. * **ui:** Name venue setup for the venue on a bridged order, and label a buy's potential return "To win" instead of "Payout". * **ui:** Preserve quarter-line precision in sports line selectors and signed spread outcome labels. * **ui:** With event discovery URL persistence on, the home page no longer waits for `GET /categories` before rendering its first event list when the URL names no category or subcategory. The default "all" tab is known up front, so the first titles paint as soon as the list arrives. * **ui:** Switching markets reuses the image the event page already decoded, so the order ticket updates without a loading flash. A position opens that market on the sell side for the full size. Profile tabs stay in view while the position rows scroll. A subcategory click highlights that row before the homepage re-renders the event list. * **ui:** Order book depth bars animate with `transform: scaleX` instead of `width`, so live book updates no longer re-lay out every row. The bars look and animate the same. * **ui:** Keep market quotes and submission tied to the current buy/sell amount while input debouncing settles, and skip buy previews below the existing UI minimum. * **ui:** Smoother event list scrolling and search results fixes: * Event cards keep their lazy-market placeholder row still until a page of markets is actually loading, and the event list's pagination skeleton tiles stay still until the next page is fetching. Idle infinite pulses forced a main-thread layerize every frame and capped list scrolling at 60fps. * Order book rows only re-render when a level's price, size, total, venues or depth bar changed, instead of re-rendering the whole depth on every live update. * Full search results now use the scrollable venue strip, so the venue filters no longer overflow the header, and the category tabs stay visible so results can be filtered by category. **2026-10-20 · v4.28.2** * Add `priceChange24h` to venue market outcomes: the outcome's price change over the last 24 hours in absolute probability points (`0.05` = up 5 cents). `null` means unknown, never zero. **2026-10-17 · v4.28.1** * `useRecurringCryptoMarkets` now loads a live recurring market's timeline in one request: the timeline query is bounded to the windows the pills render (two back, four ahead), and the viewed window is found inside it instead of by a separate lookup. **2026-10-16 · v4.28.0** * **sdk, hooks, ui:** Search: the `deep` option on `search()` / `useSearch` is deprecated and no longer requests the server-side reranker. `useMarketSearch` submits and the home page full results now run a normal full search. The option still type-checks, so existing code keeps compiling; it will be removed in a future major version. * **sdk:** Clarify effective fill-price reporting in order responses. `executionPrice` is filled USD divided by received/sold shares, including fee effects already reflected in those quantities. Missing fill quantities produce `null` rather than a quoted-price fallback; `quotedPriceRaw` remains the separate quote reference. * **sdk:** Keep active WebSockets authenticated when sessions refresh, and reset private event delivery safely when accounts change. * **sdk, hooks:** `handleAuthCallback()` now throws `AggIdentityExistsError` (`code: "identity_exists"`, `existingMethod`, `attemptedMethod`) when an app that refuses duplicate identities sends a sign-in back to the user's existing method. `requestAggAuthChooserOpen()` accepts an optional `{ highlightMethod }`. `UserProfile` gains optional `otherIdentities`. * **ui, auth:** When an app refuses duplicate identities for a shared email, the sign-in chooser now reopens on the user's existing method with a short explanation, and after they sign in offers to add the method they first tried. The profile shows a notice when the user has another identity with the same email. Upgrade before enabling "Use existing sign-in for shared emails" in the admin dashboard. * **ui:** User profile: resolved positions in the Closed tab are no longer hidden by the small-position share filter. **2026-10-15 · v4.27.7** * Venue-account balance rows from `getManagedBalances` carry `registeredByAgg`: `true` when AGG registered the TipRun account for the user, `false` when AGG reused an account the user already had. **2026-10-15 · v4.27.6** * `executeManaged` accepts an optional `externalId`, your own idempotency key for the trade. It is saved on every order the quote splits into and carried on each `trades.filled` webhook, and reusing it on a different quote returns 409. `getManagedBalances`, `getPositions`, `getExecutionOrders`, `getUserActivity` and `getDepositAddresses` accept `{ signal }` to cancel the request. Execution orders carry `cancelReason`, and the event detail carries `settlementDiffStatus` (`computed`, `pending` or `not_applicable`) to explain a `null` `settlementDiff`. **2026-10-15 · v4.27.5** * `getExecutionOrders` accepts `clientOrderId` to look up a limit order by the id you placed it with — for example, after a retry returns 409. `TradesFilledEvent` now carries `clientOrderId` (a limit order's id, `null` otherwise). **2026-10-14 · v4.27.4** * Keep active WebSockets authenticated when sessions refresh, and reset private event delivery safely when accounts change. **2026-10-14 · v4.27.3** * Document that `OrderbookState.marketId` holds the outcome id, not a venue market id, and deprecate it in favour of `outcomeId`. WebSocket orderbook frames are keyed by outcome and carry no market id, so `snapshotToOrderbook` sets `marketId` to the outcome id. The same applies to `useAggOrderbook`: its `marketId` option and `data.venueMarketId` hold the outcome id. Use the REST `venueMarketId` or your own outcome → market mapping when you need the market. No behaviour change. * **sdk:** Document that `SmartRouteResponse.positionAvailability` is returned on every sell quote, `ok` included, not only on `insufficient_position`, and that it is a snapshot rather than a reservation. **2026-10-14 · v4.27.2** * Document `executionPriceRaw` on order events as the fill's own price, `filledAmountRaw / actualSharesRaw`, rather than the quote. A sell's is net of fee; most venues report a buy's fee separately in `actualVenueFeeRaw`. On Hyperliquid the `filled` event still carries the quote; the realized price lands on the order shortly after. **2026-10-14 · v4.27.1** * Sports event pages no longer render regulation-only books as a second Moneyline card. A market whose question names a 60-minute regulation window ("Dallas Stars To Win (60 Min)", "Draw (60 Min)") settles a regulation tie differently from the overtime-inclusive moneyline, so `isBinarySportsMoneylineMarket` now rejects it. Its win/draw/win legs group into one "Regulation Time (3-Way)" card with a pill per leg. **2026-10-13 · v4.27.0** * **sdk:** Expose optional accepted price bounds and orderbook freshness metadata on smart-route quotes. * **sdk:** Submit order signatures before optional Polymarket buy fee pre-signs, and bound the optional fee prompt so a rejection or unanswered signer cannot discard the order. * **sdk:** Wait up to 55 seconds (was 15) for an optional Polymarket buy fee pre-sign, so a slow wallet's approval is used instead of being discarded and asked for again after the fill. The order is never held by it. * **sdk:** Recognize the ProphetX venue-rate-limit code and venue in public error response types. Preserve retry hints without automatically replaying execution requests. * Modernize package builds with tsdown, preserve CJS/ESM exports, and share the SDK's public type contracts across hooks. Add opt-in React Compiler output with a React 18-compatible runtime while validating applications on React 19. * **sdk:** Do not sign an optional Polymarket buy fee pre-sign until the fill's order signatures are submitted. A fee prompt listed before the order prompt no longer stops polling and holds the order for up to 55 seconds. * **sdk:** Sign an optional Polymarket buy fee pre-sign only after the Polymarket order signature itself is submitted. An auth or setup signature posted before the order prompt exists no longer unlocks it, so it cannot hold the order for up to 55 seconds. * **sdk:** Withdrawal status sources can carry an optional `pendingReason` (for example `awaiting_venue_approval`) while a venue-account source is submitted but not yet confirmed, so apps can show what the withdrawal is waiting on. * **hooks, ui:** New opt-in `features.uncrossMergedOrderbook` UI config flag (default `false`). When enabled, the merged cross-venue orderbook in `MarketDetails` and the chart orderbook nets locked or crossed levels — one venue's bid at or above another venue's ask — the way a single matching engine would, so the same price never appears on both sides and the spread is never negative. `mergeVenueOutcomeOrderbooks` takes the matching `uncross` option. Display only; quotes still route against the real venue books. * **hooks:** `useSettlementDetailsData` scopes its selected-market lookup by the market's own `venueEventId`, so `/venue-markets` returns the market's description and rules again. The settlement panel showed no rules when the lookup was by `externalIdentifier` alone. * **hooks, ui:** Background smart-route polls no longer block the BUY button. `PlaceOrder` used to swap the CTA for "Refreshing quotes…" on every poll; it now keeps the current quote usable and updates the route cards in place when the response changes. The CTA is still disabled while a quote for a new amount loads. `useSmartRoute` now reports `loadingReason: "updating-route"` when the previous amount's quote is shown as placeholder data. Before, it reported `"refreshing-quotes"` because TanStack Query v5 leaves `isLoading` false in that case. * **ui:** Pin only the highest-volume moneyline card on sports event pages; other moneyline-typed cards (e.g. a Tie leg) sort by total volume with the rest. * **ui:** Show the settled per-share price on resolved split-payout winners (for example a 50/50 tie paying 50¢) instead of a flat 100¢ in the profile positions list. * **ui:** Sort sports event market cards by total volume (every market in the card plus its matched cross-venue markets), keeping moneyline cards pinned on top, instead of a fixed moneyline → spread → total order. * **ui:** Always show the period heading on sports period tabs (Halves, Quarters, …), so a tab holding only one period still says which half or quarter its lines are for. **2026-10-06 · v4.26.1** * Show settlement differences under the event chart again when the event page loads from the bootstrap payload. The summary now loads the deferred event details itself instead of reading the empty bootstrap value. `useSettlementDetailsData` accepts an omitted `market` for event-only loads. **2026-10-05 · v4.26.0** * **sdk:** Leaderboard by category, league and event, with win rate. `getLeaderboard()` takes `categoryIds` (one category; a league is a category), `venueEventIds` (up to 5 events, merged across venues), `userIds` (up to 100 users) and now forwards the single-user lookups (`userId`, `externalId`, `username`, `walletAddress`). Rows gain `wins`, `losses` and `winRate`: wins and losses count settled position legs by the sign of their settlement P\&L (a zero settlement, such as a void, is neither; a position sold before settlement counts toward P\&L only), and `winRate` is wins / (wins + losses), `null` before any settlement. `getLeaderboardSeries()` takes `categoryIds`, makes the user optional (no user = the whole app's daily totals), and returns `wins`, `losses`, `winRate`, `cumulativeWinRate` per point plus range `totals`; `userId` is `null` for an app-wide series. `getPositions()` / `getExecutionPositions()` take `categoryIds` and `sort: "realizedPnl"`, and each position outcome carries `realizedPnl`. * `BatchOrderbooksResponse` gains optional `sizeStepRaw` and `minSizeRaw` (6-decimal share units) next to `tickSize` on each `venueOrderbooks` entry, each `matchedMarkets` item and `requestedMarket`: Polymarket 0.01 share, Hyperliquid whole shares, PRED 0.001 share and TipRun 0.01 share, plus the market's minimum order size. Other venues omit both. `useLimitOrderVenueQuotes` now returns each venue's `sizeStepRaw` and `minSizeRaw` from that orderbook metadata, plus `isMetadataPending`, and `resolveLimitOrderSizeRulesByVenue` is exported. The `PlaceOrder` limit form takes its size step and minimum from the market's orderbook metadata instead of the per-venue quote, so they no longer depend on the entered amount or on a quote resolving. Submit on a venue with a size step waits only for that metadata to load. Venues with no step keep the same sizing as before. * **sdk, ui:** `SmartRouteResponse` gains optional `sizeStepRaw` and `minSizeRaw` (6-decimal share units). A quote restricted to one venue (`allowedVenues` of length 1) returns that venue's limit-order size step and minimum: TipRun from the contract, Hyperliquid whole shares, PRED 0.001 share, and Polymarket 0.01 share plus the market minimum. Other venues omit both. The `PlaceOrder` limit form floors the order size to that step, so a dollar-based limit buy never spends more than you enter, marks a venue whose minimum the amount misses as unavailable so another venue is picked, and blocks submit when every venue is below its minimum. Venues with no step keep the same sizing as before. * **sdk:** Add a per-outcome orderbook watchdog to `AggWebSocket`. Sockets from `AggClient.createWebSocket()` now check, once a minute, any subscribed book with no update for 5 minutes against `GET /orderbooks/outcomes`. If the server's `seq` matches, the market is just quiet and nothing happens. If it differs, the SDK requests a resnapshot; if the next check still differs, it unsubscribes and resubscribes that outcome. A book whose snapshot never arrives, or whose recovery stalls, is retried the same way. Outcomes the server can't serve are counted as checked, so a large subscription set rotates through every book. Each check costs one market-data quota unit per silent outcome (at most 100 per call), is abandoned after 15 seconds, and pauses for `Retry-After` on a 429. New diagnostics: `book_watchdog_resync` and `book_watchdog_error`. On by default for `createWebSocket()`. Pass `{ bookWatchdog: false }` to turn it off, or `{ bookWatchdog: { silenceMs, checkIntervalMs } }` to tune it. A directly constructed `AggWebSocket` enables it with the new `bookWatchdog` option. `AggWebSocket.authenticate(token)` now also stores the token for reconnects, so a socket that reconnects after a token refresh authenticates as the user instead of dropping to app level with the old token. * **sdk, ui:** Sell quotes report `estimatedNetProceeds`, net of partner app and referral fees; the place-order proceeds use it. * **hooks, ui:** Limit-order tick and size rules are now fetched for every venue's own market, not only the selected one, so matched venues such as TipRun round sizes to their own step. **2026-10-01 · v4.25.0** * Add `referralActivationSponsorAddress` to `getSmartRoute()` and `placeOrder()`. On a user-paid referral routed to a self-custody Hyperliquid buy, the named wallet pays Hyperliquid's one-time 1 USDC activation fee for a referrer that has never received anything on HyperCore. Before the fill your `signer` receives an optional `hl_activation_sponsor` request with that wallet as `signerAddress`; declining it never affects the trade. The quote's `referral` echoes it as `activationSponsorAddress`. `fillSelfCustody()` now asks your `signer` for required requests before optional ones and still submits everything in one batch. An optional request signed by a wallet other than the user's (e.g. a partner's activation sponsor) is bounded by `OPTIONAL_SIGNATURE_TIMEOUT_MS` (15 s, exported); after that it is declined and the required signatures go out without it, so a hung sponsor signer can delay the order's signature by at most that long. Optional requests the user's own wallet signs (such as an `hl_referral` prompt) are awaited as before. **2026-10-01 · v4.24.0** * **sdk:** Add `getLeaderboardSeries()` for `GET /leaderboard/series`: one user's leaderboard stats (orders, volume, spent, realized P\&L) per UTC day over a range (default 30 days, max 366), with a running cumulative realized P\&L, ready to chart. Look the user up by exactly one of `userId`, `externalId`, `username` or `walletAddress`. P\&L is gross of venue and app fees; unrealized P\&L is not included. * Add an opt-in `minVolume` filter. `getVenueEvents`, `getVenueMarkets`, `getVenueEventById` and `getEventBootstrap` accept `minVolume` (USD lifetime volume floor); `useVenueEvents`, `useVenueMarkets`, `useVenueEvent` and `useEnrichedVenueEvent` forward it; set `config.market.minVolume` on `AggProvider` to hide events and markets below the floor across the events list, event cards, the event page and the sports event page. Unset by default — no behavior change. `useVenueMarkets`' React Query key gains a trailing `minVolume` slot (now 17 elements), and `useVenueEvent`'s key gains one too (now `["venue-event", eventId, cluster, minVolume]`); code that hand-builds either key for `setQueryData`/`getQueryData` must append it. * **sdk:** Add per-trade venue-specific fees: `appFeeBipsByVenue` and `referrerFeeBipsByVenue` on `getSmartRoute` and `placeOrder`, with the per-venue rates reported on the quote's fee breakdown. **2026-10-01 · v4.23.0** * Add Novig Direct account connections, per-organization execution mode, and native-cash trading in the shared order ticket. Users connect a Novig v3 trading key for a funded subaccount; wallet and clearinghouse funds remain separate. * **ui:** Show the shares a BETDEX runner holds when its market is folded under another venue's Yes/No market, so Sell works from that page. In Manage balances, venue badges wrap instead of overlapping the refill status when a network lists four venues. * **ui:** List BETDEX with the other Solana venues in Manage balances, and drop its separate row from the Balance card. **2026-09-30 · v4.22.6** * Sports spread and combined-leg outcome pills now take their price and venue logo from the same cross-venue selector as every other market pill (live best price first, then REST), instead of a fixed REST midpoint paired with the anchor venue's logo. A spread pill could previously show a price no venue offered. **2026-09-30 · v4.22.5** * Sports spread/total cards rank lines by the volume of the whole matched cluster (every venue offering the line), not just the anchor venue's market, when choosing the default line. **2026-09-30 · v4.22.4** * Sports spread/total cards now open on the main line. Among lines priced within 30–70¢ the highest-volume one is the default; with none in that band, the line closest to 50/50 is. The card follows that default once live midpoints load, unless the user or URL already picked a line. **2026-09-30 · v4.22.3** * Order and fee types now carry field descriptions and exact value sets: order `status` is typed with every order status, `fees.venueFeeBasis`, `fees.actual.appFee.status` and `fees.actual.referral.status` are typed unions, and money fields document their units. No runtime behavior changes. * Clarify `redeemStatus` and redeem leg `ineligible`: it now covers outcomes that lost, venues that settle on their own, and self-custody positions claimed on the venue. Documentation only; no runtime behavior changes. **2026-09-30 · v4.22.2** * Sports spread/total cards now open on the main line. When line volumes tie (most lines report zero volume), the rail defaults to the line priced closest to 50/50 instead of the lowest rung, and follows it once live midpoints load unless the user or URL already picked a line. **2026-09-29 · v4.22.1** * `WsResolvedOutcome` (the `market_resolved` stream event) now carries optional `payoutPerShare` and `refundAtCost`, so a voided or split market (e.g. `payoutPerShare: 0.5` on every side) can be told apart from a \$1 win. **2026-09-29 · v4.22.0** * Add the `trades.fee_reconciled` webhook event type (`TradesFeeReconciledEvent`). It fires once per self-custody trade when the app fee reaches its final state, a few seconds after `trades.filled`, with the full fee breakdown and transaction hashes. **2026-09-29 · v4.21.2** * `SignatureRequest` gains `optional`. `fillSelfCustody` now skips an optional request (such as a Hyperliquid partner referral, `purpose: "hl_referral"`) when your signer throws or it has expired, instead of failing the fill; required requests behave exactly as before. **2026-09-29 · v4.21.1** * Restore compact venue badges and outcome pills on event comparison charts. Show only the three highest-volume markets for the selected venue, keeping both Yes/No histories available and allowing each to be hidden independently. Remove the series pager and bound chart requests and live subscriptions to visible outcomes in those three markets. * **ui:** Show a single-market 1X2 result (team / Draw / team) as the Moneyline card once it anchors the event's matched moneyline legs. **2026-09-28 · v4.21.0** * Use Trading access for BETDEX configuration and the normal wallet transfer flow for funding. Show real venue prices in Smart Routing before funding. The Buy action prepares the user's owned account, journals an idempotent transfer, waits for native credit and obtains a fresh executable quote within the original spend and slippage. * **hooks, ui:** Replace the event list venue tabs with a logo strip. Matched · All stay as pills, then each venue is a 28px logo tile that names itself on hover and when active. Venues that do not fit the width the strip is given fold into a `+N` chip that lists the folded venues; the active venue always stays in the strip. On phones the strip spans the row and Matched · All shrink to glyph tiles. The strip is exported as `EventListVenueStrip`, and a deep-linked venue is no longer reset while `/app/config` is loading. Hooks adds the `eventList.moreVenuesAria` and `eventList.moreVenuesListAria` labels. * **ui:** Submit executable BETDEX quotes through the normal managed order API. The server prepares any required wallet transfer or bridge, confirms credit in the user's owned account, and executes the accepted order; the Buy button no longer runs a separate client funding sequence. **2026-09-27 · v4.20.1** * Support self-custody post-fill fee authorization operations for HyperCore and Polymarket wallets, including user-paid referrals, while polling through fee completion. **2026-09-26 · v4.20.0** * Add `appFeeBips` to `getSmartRoute` and `placeOrder`: a per-trade app fee (0–10000 bips) that replaces the app's configured rate. Honoured only on requests authenticated with an app API key. **2026-09-25 · v4.19.3** * Outcomes now include `refundAtCost`. When a venue voids a market and returns every holder their stake (ProphetX), both outcomes carry `winner: true`, `payoutPerShare: null` and `refundAtCost: true`. The refund per share is the holder's own average entry price. Check `refundAtCost` before `payoutPerShare` and the `winner ? 1/winnerCount : 0` fallback. * `skipBalance` on `placeOrder` no longer requires `skipQuote`. Without `skipQuote` it keeps the smart route but skips the quote-time balance refresh, so the route uses the last stored balances; the fund hold and balance check still run. Requires a `read_write` API key. **2026-09-25 · v4.19.2** * Venue markets and their matched cross-venue siblings now include `volume24hr`, the rolling 24-hour USD volume reported by each venue. A value of 0 can mean the venue does not report a 24h figure. **2026-09-25 · v4.19.1** * Use Trading access for BETDEX configuration and the normal wallet transfer flow for funding. Show real venue prices in Smart Routing before funding. The Buy action prepares the user's owned account, journals an idempotent transfer, waits for native credit and obtains a fresh executable quote within the original spend and slippage. * **ui:** Submit executable BETDEX quotes through the normal managed order API. The server prepares any required wallet transfer or bridge, confirms credit in the user's owned account, and executes the accepted order; the Buy button no longer runs a separate client funding sequence. **2026-09-25 · v4.19.0** * **sdk:** Add `cancelVenueOrder()` to cancel a Polymarket or Hyperliquid limit order a linked self-custody wallet placed outside AGG. `cancelManagedOrder()` now calls `POST /execution/orders/cancel`; `POST /execution/orders/{orderId}/cancel` is deprecated. * **sdk:** Add the `deposits.confirmed` webhook event type (`DepositsConfirmedEvent`) to the server-side webhook types. * Add the trader leaderboard * `client.getLeaderboard(params?)` calls `GET /leaderboard`: traders ranked by realized P\&L or volume over an inclusive UTC day range, cursor-paginated, `rank` exact within the top 100. * `useLeaderboard({ period, metric, limit })` wraps it with the `1d | 7d | 30d | all` presets and flattens pages; `leaderboardRange(period)` is exported for custom UIs. * `LeaderboardPage` (`@agg-build/ui/pages`) renders the board with period and rank-by controls, a highlighted "You" row, and identity fields gated by the app's `leaderboardIdentity` setting. New `labels.leaderboard` block for copy overrides. **2026-09-24 · v4.18.2** * Show BETDEX prices, history and artwork: re-poll incomplete midpoint responses instead of caching them for the session, seed BETDEX outcome books on market cards, start the initial chart range at market creation when the start date is a future kickoff, explain missing BETDEX hosted cash instead of reporting no orderbooks, and show the venue mark when an event or market has no image. * **ui:** Show existing pre-kickoff history and selected native outcome prices in sports pages, and use venue artwork for an image-less order ticket. **2026-09-24 · v4.18.1** * Add a BETDEX venue tab to the event list. Like other opt-in venues, it stays hidden for apps that disable BETDEX, and while app config is still loading. * Show BETDEX and Tip Run in the default event venue selector when the app enables them. **2026-09-24 · v4.18.0** * Add explicit BETDEX hosted-account setup, account status and venue-held USDC balances. Hosted balances remain separate from signing-wallet balances. The profile uses the existing labels and UI primitives, distinguishes sandbox funds, and disables custody in paper mode. BETDEX remains disabled by default. Add owned BETDEX account activity to the API, SDK, hooks and expandable profile view. Exact signed ledger entries retain their native precision and distinguish credits, transfers, payouts, refunds and commissions. They are not presented as spendable cash, per-order fees or withdrawal-completion evidence. Describe BETDEX's published fee as 1% of net market winnings and explain its settlement timing in quote previews. Actual per-order charges remain unknown until supported by authoritative settlement evidence. * Register and activate the BETDEX venue identifier, labels and native logo. BETDEX remains disabled by default for existing apps. * Add an optional native Worker browser adapter and framework-independent market-data engine. The demo runs the shared socket protocol, incremental orderbook checksums, candle accumulation, and chart history decoding off the main thread with bounded delivery. Hooks use per-outcome external stores, websocket-first snapshot selection and one abortable batch fallback. SDK browser, Node and React Native defaults remain compatible; browser-only APIs use the separate `@agg-build/sdk/browser` entry point. Lean bootstrap settlement details load for the selected visible market only, with centralized loading/error/retry states and request cancellation. Market-data reads now have scoped rate budgets and expose server retry hints through `AggApiError.retryAfterMs`. Search and chart hooks honor those hints without adding automatic trading retries. Public API guides document the endpoint contracts, quotas and Worker lifecycle. * Show a user's open limit orders on the event page. A new card between the event hero and the markets list lists every resting limit order on the markets on screen — the whole matched cross-venue cluster — with its limit price, live mark, fill progress, total and expiration, and cancels in place. The card is gated on the `showLimitOrders` feature flag and hidden in paper mode, matching the trade panel and the profile, and states a failed order query instead of rendering as empty. Adds `eventMarketPage.openOrders` labels and an `openOrders` entry to `EventMarketPageClassNames`. `getExecutionOrders` and `useExecutionOrders` accept `venueMarketIds` to scope the order list to a set of venue markets. New `useExecutionOrdersForMarkets` takes any number of markets, batches them 75 per request to keep URLs short (as `getMidpoints` does) and merges the results. The event page uses it, so the card loads with one request for nearly every event instead of one per open status. * Support organization-configured ProphetX Direct accounts alongside clearinghouse execution. Add the ProphetX connection modal, isolated venue cash balances, execution-backend metadata, and cancellation events. Direct accounts use their own ProphetX funds and do not support funding transfers through AGG. Direct positions settle on ProphetX; native selling is not exposed by the current integration. * **sdk, hooks:** `getManagedBalances` and `useManagedBalances` accept `custody: "self"`. It returns cash held by the wallets linked to the account — plus their Polymarket deposit and legacy Safe/proxy wallets — instead of the managed wallet. Each chain row then carries `address` and `custodyKind` (`EOA`, `DEPOSIT_WALLET` or `POLYMARKET_LEGACY`, with `legacyKind` naming the legacy shape). Omitting `custody` returns exactly what it did before. * Represent native venue collateral with `custodyKind: "VENUE_ACCOUNT"` and `sourceVenueAccountId` / `sourceExternalAccountId` on smart-route allocations, including solo quotes. Native IDs remain strings; these are account claims, not EVM addresses. Existing EOA and deposit-wallet allocation shapes remain compatible. The SDK also exposes the existing optional `sourceAddress` field. Execution previews do not show native TipRun cash as an on-chain bridge merely because another venue uses the same source chain. This contract change does not enable TipRun or publish/deploy a live integration. Register Tip Run in the venue enum, official logo and consumer labels. Preserve native account identity/freshness on managed cash, positions and withdrawal responses, with a separate native-cash row in balance management. Quote refresh keys ignore observation-clock-only changes so balance polling does not abort an otherwise unchanged route. Existing EOA and other venue behavior is retained. * **sdk:** Self-custody fills that include a Kalshi leg are now admitted. Kalshi is funded from the user's own venue cash and signed with the user's own API key, so a mixed Polymarket + Kalshi route with `signingAddress` no longer returns `quote_self_custody_unsupported_venue`. A self-custody redeem-only fill on Kalshi now returns `quote_self_custody_redeem_unsupported` instead of `quote_self_custody_unsupported_venue` (both terminal). On apps that charge a partner fee (or user-paid referral), a Kalshi-only self-custody fill is still refused with `quote_self_custody_app_fee` — there is no funding batch for the fee to ride; mixed routes with an EVM leg collect the whole-quote fee there and are fine. * **hooks, ui:** Add owned BETDEX deposit and withdrawal controls, with exact USDC amounts, persistent withdrawal intents and status recovery after reload. * **hooks, ui:** Add a probability chart presentation with native historical samples, precise percentage labels, real aggregated orderbook liquidity, and optional multi-outcome overlays. Both sides keep independent histories and can be hidden through the chart legend. Short recurring contracts use their own opening and closing times. Their live edges advance with the clock between quote updates and stop at close, without writing synthetic observations to the history cache. Existing streaming and candlestick presentations remain available, and partner applications can opt into the event overlay with `defaultChartView="all"`. * **hooks:** Cancel obsolete event-list requests when filters change or the last consumer leaves. Avoid restoring pagination while disabled, another request is pending, or a page has failed, while preserving completed cached pages and shared consumers. * **hooks, ui:** Show BETDEX hosted cash in the trade panel, check route funding against the correct account, and open BETDEX account controls for native funding needs. * **hooks, ui:** Limit orders get the same submitting and success states as market orders: a progress view with a "`` limit order" row while the order is sent, and a success view that shows the order amount at its limit price and the payout if it fills, with no "Better" line. Adds the `orderAmount`, `orderLimitPrice`, `limitVenueOrder`, `limitOrderSubmitting` and `limitOrderSubmitted` trading labels. * **hooks, ui:** Serve 2x image variants to high-density screens so event and market thumbnails render sharp, falling back to the 1x variant for assets that do not have one yet. * **hooks, ui:** Keep visible market-card prices live within a bounded subscription budget, retaining selected and expanded cards when offscreen and releasing other subscriptions on viewport exit or cleanup. Preserve explicit parent-owned streams and standalone live-card behavior. Scope live selectors to the relevant market cluster. Honor disabled market queries in eager pagination so a bootstrap-seeded progressive event does not silently fetch every remaining page. Add a 150-market Storybook state and public performance notes. Public hook signatures and REST/WebSocket contracts remain unchanged. Bound the all-markets comparison chart to eight active outcome series with accessible pagination and a retained selection. Hidden and off-page lines release history observers and live subscriptions; usable series render while other series warm. Centralized labels and a 150-market comparison story document the controls. * **ui:** Event list design QA: 40px venue tab bar and sort select, skeleton tiles instead of dimmed results while filters refetch, venue and sort reset on category change, category folders that collapse when the path is controlled, and footer venue logos that reveal their colours on hover. * **ui:** Use BETDEX outcome titles in order-book headers and last-price labels while preserving native routing identities. * **ui:** Loading skeletons no longer blend together: the event hero's chart block is inset like the loaded plot (so it no longer fuses with the toolbar pills above it), and the order panel shows a skeleton block instead of a literal "\$0" amount. * **ui:** Match loading skeletons to the components they stand in for (event tiles, event list header and sidebar, event hero, market rows, order panel, settlement, search results, profile positions) so content no longer shifts when it loads. * **ui:** Keep comparison chart legend widths stable while history loads, prices update, or series become hidden/unavailable. Reserve the configured status text width without adding it to accessible names, and keep long labels within narrow viewports. **2026-09-23 · v4.17.4** * Add `totalCostIncFees` to `VenueSoloQuote` Each single-venue quote now carries `totalCostIncFees`: the router's total cost plus that venue's own app fee and user-paid referral fee — the same composition as the route's `totalCostIncFees`. **2026-09-22 · v4.17.3** * Positions whose shares Agg has no purchase record for — bought directly on Polymarket or Hyperliquid, or transferred in — no longer render a fabricated entry price and profit. When the venue has no acquisition price we can reconcile, the API marks the outcome `costBasisUnknown` and the profile now shows "—" for entry price, amount traded and P\&L instead of 0¢ and a 100% gain. Quantity and current value are known and still shown. **2026-09-21 · v4.17.2** * Show the Kalshi balance as a row inside the profile Balance card instead of a separate card, and remove it from the header and profile menu. The `kalshiBalance` prop on `AuthConnectButtonView` is removed, and the default `userProfile.balance.availableBalance` label is now "Balance". * **hooks, ui:** Reworked the Connect Kalshi modal to match the design. It now opens scrolled to its header (the API Key ID input is focused explicitly rather than racing Radix for it), renders the Kalshi logo at 60px on desktop and 40px inline with the title on mobile, and drops the non-interactive "Having trouble?" help links. Below `md` it lays out as a bottom sheet with left-aligned copy and a sticky Cancel / Verify Connection footer; the success confirmation uses the narrower 480px card with a tinted success mark and a secondary Done action. `Modal.Container` accepts an `onOpenAutoFocus` handler so a modal can place initial focus itself instead of competing with Radix's default. The now-unused `onboarding.connectKalshiModal.help` labels have been removed. * **hooks, ui:** When the user has no Kalshi account connected, Smart Routing now selects the next best route instead of Kalshi, and the Kalshi route card shows a warning icon with "Requires Kalshi account". Kalshi stays selectable; choosing it still prompts the user to connect, and that button now reads "Connect Kalshi to trade". * **hooks, ui:** Show the side and the live price on open limit orders. The outcome pill now reads "Buy Yes" / "Sell Yes" instead of the bare outcome, and the limit price carries the current market price beneath it ("now 25¢"). The mark is matched at the outcome level from `GET /midpoints`, so the two sides of one market show their own prices; an order whose outcome has no live book keeps the limit price alone rather than showing a guessed mark. * **ui:** Honor the Closed bucket for resolved Kalshi winners and display their Won result on desktop and mobile rows. * **ui:** Settlement: list every cluster venue on each key difference and under Full rules by venue, drop differences whose venues all say the same thing, stop repeating the question in the body when the description restates it, hide the cross-venue differences section for single-venue clusters, and scope the venue-rule hover state to its toggle. * **ui:** Remove the average-price liquidity warning banner from the order panel while preserving regional restrictions, fee disclosures, and footer spacing. **2026-09-18 · v4.17.1** * Ignore funding fields with `skipQuote` `allowedSourceChainIds` and `chainBalances` are now ignored when `skipQuote` is set, instead of returning 400. A `skipQuote` order is always funded on the venue's own chain. **2026-09-18 · v4.17.0** * Support `PT30M` and `PT4H` recurring crypto cadences `RecurringCryptoDuration` now includes `PT30M` and `PT4H` alongside the existing `PT5M`, `PT15M`, `PT1H`, and `P1D`. The API can now return recurring crypto up/down markets on these cadences (Hyperliquid sub-daily windows), and the SDK, hooks (slug/aggKey parsing, canonical window polling), and UI (recurring cadence labels) recognize them end to end. `RECURRENCE_CADENCES` — the cadence list behind the `recurrence` filter on `listVenueEvents` / `useVenueEvents` — gains the same two entries, so filtering venue events by them type-checks. * **sdk:** Add `skipQuote`, `skipBalance` and `price` to `PlaceOrderParams` `POST /execution/orders` can now skip the smart-route solver (`skipQuote`) and, on buys, the balance check (`skipBalance`) to send one order straight to predict or hyperliquid. `price` sets the trade price for a `skipQuote` order. These optional fields let a TypeScript partner pass them without a cast. * **sdk:** Document `skipQuote` on novig and prophetx `PlaceOrderBody.skipQuote` now lists novig and prophetx alongside predict and hyperliquid. `price` is ignored on prophetx, which prices the order itself within `slipCapBps`. **2026-09-16 · v4.16.0** * Expose `minOrderSize` and `tickSize` on venue markets Limit orders are placed without a quote, so nothing validated size or price before the request. `GET /venue-markets` (and the markets embedded in venue events) now carry `minOrderSize` and `tickSize`. `minOrderSize` is populated for Polymarket (the market's own floor, the exact value `POST /execution/limit-orders` validates `sizeRaw` against) and PRED (0.001). Elsewhere it is `null`, which means no value was ingested — not that the market has no minimum; other venues enforce floors this field does not carry. Align `limitPriceRaw` to the runtime-inferred `tick` from the orderbook endpoints where one is available; `tickSize` is the ingestion-time seed and the live grid can be finer. **2026-09-16 · v4.15.2** * Expose source-chain restrictions and per-chain funding budgets on direct order requests. **2026-09-15 · v4.15.1** * Discovery outcomes now include `payoutPerShare`. Events, markets, and outcomes responses return each outcome's settled per-share payout alongside `winner`. It is set when a market pays more than one outcome — a Polymarket 50/50 pays 0.5 on each side, a Novig void refunds each side at its price — and is `null` otherwise, in which case `winner ? 1/winnerCount : 0` still applies. **2026-09-15 · v4.15.0** * **sdk:** `placeLimitOrder` accepts `fundingAddresses` for self-custody limit buys. Pass other wallets linked to the same account alongside `signingAddress`. Their balances can fund the buy by bridging in, the same way a self-custody market fill funds from several wallets. On the venue's own chain only the signing wallet's balance is used. * **ui:** Paper-mode and card interaction fixes. * `PlaceOrder` no longer asks a user to connect Kalshi when the app is in paper mode. Paper fills are simulated and never use venue keys, matching how the shared KYC prompt is already skipped for paper orders. * `EventListItemDetails` is only clickable when an `onClick` is passed. It previously always attached a click handler, so the event hero showed a pointer cursor with nothing to click. * `Card` only lifts on hover (`hover:shadow-agg-card-hover`) when it is interactive (`onClick` or `href`). Static cards keep their resting shadow. **2026-09-15 · v4.14.0** * Add `label` and `showWhenAuthenticated` props to `PaperTradingBadge`. `label` overrides the pill text for one placement (defaults to `labels.paperTrading.badge`). `showWhenAuthenticated` keeps the mobile `brand` pill visible for signed-in users, which is hidden by default because the full label does not fit beside the wallet chip. Both are optional; existing usage renders exactly as before. **2026-09-14 · v4.13.2** * Group order book rows to the nearest 0.1¢. Venues that quote finer than 0.1¢ (Hyperliquid, PRED, ProphetX) produced several order book rows that all displayed the same price, e.g. three "8.5¢" rows. Levels are now grouped to the nearest 0.1¢ — the precision the order book renders — with shares and venue breakdown summed into one row, including across venues. Total still sums each level's real price × size, and spread and midpoint use the real best bid and ask. This is display-only: quotes and execution use the ungrouped venue books. * Show ProphetX and Novig in the profile modal's Trading access tab. The tab lists venues from a fixed set that did not include ProphetX or Novig, so enabling either venue for an app never added its row. Both now appear alongside the other venues and follow the same app-config and geo-block rules. **2026-09-14 · v4.13.1** * Type `client.verify()` for Privy sign-in. `client.verify({ kind: "privy", privyToken })` already worked at runtime, but `AggAuthVerifyBody` only allowed `{ message, signature }`, so TypeScript callers got a compile error. `AggAuthVerifyBody` is now a union matching `POST /auth/verify`: ```ts theme={null} // wallet (SIWE / SIWS) — unchanged, `kind` may be omitted { message, signature, earlyAccessCode? } // privy { kind: "privy", privyToken, earlyAccessCode? } ``` The two branches are also exported as `AggAuthWalletVerifyBody` and `AggAuthPrivyVerifyBody`. Existing wallet callers need no changes. **2026-09-14 · v4.13.0** * Accept a Privy access token at `POST /auth/verify`. Partner apps that use Privy as their wallet provider can let users sign in with a token minted by that same Privy app, alongside the existing OAuth, email, and wallet-signature methods. There is no separate Auth-tab setting: saving Privy under Settings → Wallet Provider turns it on. `VerifyBody` becomes a union discriminated on `kind`: ```ts theme={null} // wallet — unchanged, and `kind` may be omitted { message, signature, earlyAccessCode? } // privy { kind: "privy", privyToken, earlyAccessCode? } ``` **Existing callers keep working.** Every wallet caller predates the discriminator, so `kind` is optional on that branch and existing payloads still validate. Code that reads `body.message` off a `VerifyBody` value without narrowing will need a `kind` check. This extends the existing endpoint rather than adding a new one: how identity is proven is the only thing that differs between the two. Principal creation, the early-access gate, user creation, and token issuing are shared, so the branch lives in the auth strategy and the route is unchanged. Privy auth stores no extra configuration: access tokens are ES256 JWTs verified against Privy's published public keys, using the Privy app ID from the wallet provider credentials. Every failure is a 401 with a message. **2026-09-14 · v4.12.0** * Expose the linked signing wallet and signing quote ID for self-custody limit orders, and accept a fresh cancellation signature without changing existing managed-order calls. Support Hyperliquid GTC/ALO self-custody limit buys and sells, with client-held EOA or agent signing and signed cancellation payloads. **2026-09-12 · v4.11.3** * **sdk:** Add `settlementFee` to smart-route buy fills and document that `estimatedPayout` is net of it. `settlementFee` is what the venue takes from a leg's \$1 payout if the outcome wins — today only Hyperliquid HIP-4's close fee — and is not a cost at fill. * **ui:** Show and rank net payout in the place-order panel: buy routes use estimated payout after settlement fees, and sell routes show proceeds after fill fees. Keep solo routes from distinct outcomes on the same venue visible. **2026-09-11 · v4.11.2** * Document `SmartRouteBridgeStep.amount` and `SmartRouteBridgeStep.cost`. `cost` is the delivery margin sent on top of the route's allocations, not the expected bridge fee — read `feeBreakdown.bridgeFees` for that. **2026-09-10 · v4.11.1** * Fee breakdown: fold the builder fee into the Venue row instead of listing it separately, and render a partner's non-zero app fee as a dollar amount on the agg.market row. The 0% fee badge and the 0% platform fees trust line now appear only when the app fee really is zero, and the app fee is counted in the estimated-fees total. **2026-09-10 · v4.11.0** * **sdk, hooks:** Remove the `categoryIds` filter from `getVenueMarkets` / `useVenueMarkets`. Category is an event-level attribute, so filtering markets by it was slow on large categories and is better expressed as an event filter. Filter events by category instead — `getVenueEvents({ categoryIds, status })` — then fetch that event's markets with `getVenueMarkets({ venueEventId })`. * Report the builder fee as its own line in the quote fee breakdown. It was previously folded into venueFees, which labelled a partner's own charge as the venue's. Fee totals are unchanged; the builder slice is now visible separately on the route breakdown, on each per-venue breakdown, and on every fill. Also surfaces the partner app fee on quotes that are not executable and on per-venue quotes, where it was reserved from the budget but never reported. **2026-09-10 · v4.10.0** * Add PRED (`pred.app`) as the distinct `pred` venue across SDK request and response types. PRED is unrelated to Predict.fun (`predict`) and settles through a per-user Safe on Base. PRED custody uses the existing `custodyKind: "DEPOSIT_WALLET"` discriminator, qualified by `venue: "pred"` and its custody address. Existing venues' source shapes remain unchanged. Hooks and UI add the corresponding PRED labels, filters and logo. PRED is default-hidden for existing and newly created apps until an app explicitly removes `pred` from its `disabledVenues` list. **2026-09-09 · v4.9.2** * Use Clearinghouse executable prices and normalized amounts on ProphetX tickets. Preserve the reviewed spend and output limits during refresh, and show quote failures before order execution starts. **2026-09-08 · v4.9.1** * Verify that ProphetX fixed-input quotes show no execution-reserve amount or held-funds tooltip. ProphetX quote sizing uses the available book without a speculative commission or slippage reserve; other venues retain their existing behavior. **2026-09-08 · v4.9.0** * **sdk, hooks:** App config is now cached per App and refreshed on a bounded schedule. `AggClient` exposes a public `appId` getter, and `useAppConfig` / `useCachedAppConfig` key their React Query entry as `["agg", "app-config", appId]` instead of the previously global `["agg", "app-config"]`. Two clients for different Apps sharing one `QueryClient` no longer read each other's config — which matters now that the response carries the app-level `tradingMode` that every execution surface renders from. Admin-key-only clients get a `"__no-app__"` scope. Use the exported `appConfigQueryKey(appId)` to seed or invalidate the entry yourself. The query keeps its existing 5-minute `staleTime` and adds a matching `refetchInterval`, so a tab left open on one screen still picks up an App being flipped between paper and live instead of needing a reload. Nothing else about its refresh behaviour changes: `refetchOnWindowFocus` and `refetchOnReconnect` are left at the host `QueryClient`'s settings rather than forced. The interval is skipped entirely while a tab is unfocused, so a background tab costs nothing. A stale `tradingMode` is cosmetic in any case — the API resolves mode from the App on every request, so a client holding the old value renders the wrong chrome but cannot execute in the wrong mode. * Integrate shared clearinghouse KYC for Novig and ProphetX using authoritative wallet status, skip repeat verification for approved wallets, and remove the Kalshi v2 KYC prompt while retaining account connection. Add user-scoped status queries, pending-decision handling, and protection against duplicate or stale verification actions. * Separate clearing-house execution reserves from estimated quote fees. Preview-only slippage tolerance no longer inflates Novig or ProphetX funding reserves; actual execution reserves and spend-cap checks remain enforced. Quotes expose an optional executionReserve amount and the trading panel explains it separately from fees. * **sdk:** Mark ProphetX as an active, Solana-settled venue. It stays disabled by default per app until the app explicitly enables it. * **hooks:** Keep live managed balance queries refreshed every five seconds while the page is visible. This lets the shared provider and balance hook recover when a fill notification arrives before the settled balance snapshot. Existing post-trade invalidation remains immediate; paper balances retain their event-driven behavior. * **hooks, ui:** Keep temporarily unavailable venue quotes visible as disabled comparison cards, including ProphetX when clearing-house readiness fails. Preserve available price previews and support a customizable availability label. * **ui:** Paper trading UI, gated so live apps render exactly as they do today. An app is paper or live at the App level (`tradingMode` on `GET /app/config`), and the components resolve it through `useResolvedExecutionMode` from `@agg-build/hooks`. In a paper app the library now renders a "Paper trading" header pill, a three-step onboarding modal, and Buying Power / Earnings cards on the profile, and it hides the surfaces that have no paper equivalent — deposit, withdraw, venue cash balances, and the Limit order tab. **Nothing here changes how a live app renders.** Every layout change this feature needed is behind the paper branch, including the ones that would otherwise have been global: * `Header` keeps its 24px mobile inset and no brand-group gap unless a `brandAccessory` is supplied. * The sign-in button keeps the `large` preset at every breakpoint; the tighter 79x28 mobile size is paper-only, and the signed-out control is no longer wrapped in an extra flex box. * The profile summary grid keeps its `1fr / 300–420px` columns and 32px gap; the wider 626px column is paper-only. * Profile summary cards keep 20px padding and wrapping labels; the compact 16px padding and single-line label are paper-only. * A card that supplies no `caption` renders its value exactly as before, with no extra row wrapper. `Header` gains an optional `brandAccessory` slot, rendered after the logo inside the brand group. Supplying it also opts that header into the brand-group gap and the 16px mobile inset the accessory layout is drawn against — so pass it conditionally rather than handing it a child that hides itself, since the header cannot see that the element it was given rendered `null`. `Modal.Container` gains a `center` class slot for overriding the default bottom-sheet positioning below `md`. **2026-09-06 · v4.8.1** * `fillSelfCustody` now follows the `pollAfterMs` the server sends on each `/execution/status` response instead of sleeping a fixed 2s, and takes a short first poll rather than burning a full interval before its first read. The API asks for 1s today, so a pending signature request is found in roughly half the time. `SELF_CUSTODY_POLL_INTERVAL_MS` is unchanged and is still the fallback when the server does not send the field. Server values are clamped to 250ms–30s. * Add `venueMarketOutcomeId` to `VenueSoloQuote`. A venue can list the same matched outcome more than once (two Hyperliquid deployers on one game), so `venue` alone does not identify a solo card. Key on `venue` plus `venueMarketOutcomeId` when tracking a selected route. **2026-09-03 · v4.8.0** * Self-custody fills can now be funded from a legacy Polymarket Safe — the Gnosis Safe Polymarket deployed for a user before they joined this platform — at any venue, not just Polymarket. Nothing changes for wallets that never had one. * `SignatureRequestType` gains `"safe_tx"`. The payload is the Safe transaction as EIP-712 typed data, but the wallet must `personal_sign` the RAW 32 bytes of `hashTypedData(payload)` (viem: `signMessage({ message: { raw: hash } })`; MetaMask: `personal_sign` with the hex hash). The wallet shows a hash rather than a structured prompt — that is the only shape Polymarket's relayer accepts, so a plain `signTypedData` over the same struct is rejected. `payload.safeTxHash` carries the digest for convenience; the server recomputes it and never trusts the copy. As with every request type, throwing from your signer is not a rejection: the request expires and the fill fails. * `SmartRouteAllocation.custodyKind` gains `"POLYMARKET_LEGACY"`, with `custodyAddress` set to the Safe. Signers that switch on `custodyKind` should treat it as a source the user does not hold directly. **2026-09-02 · v4.7.4** * **sdk:** Correct what `approveMode` says about cost. The previous `sponsored` docblock described only the one-time ERC-20 approve and could be read as saying bridging has no cost. It does: the bridge fee is deducted from the amount delivered on every bridge, and one-time costs such as a Hyperliquid first-deposit activation still apply. The wording now describes what each mode does to the wallet — whether it is asked for gas — without implying anything about the total cost of a bridge, and points at `feeBreakdown.bridgeFees` and `feeBreakdown.setupCosts` on a `deepEstimate` quote as the figures to actually read. Documentation only; no behaviour changes. * **sdk, ui:** Surface Hyperliquid's one-time address activation fee as a setup cost. Hyperliquid charges 1 USDC to activate an address on its first deposit. Deep-estimate quotes (`deepEstimate=true`) now carry it as a `feeBreakdown.setupCosts` line with `kind: "hyperliquidActivation"`, `alreadyPaid: true` once the address exists on Hyperliquid, and it counts toward `setupCostsTotal` and `totalCost`. The fee is deducted inside the bridge leg rather than run as its own step, so the execution progress view does not render a setup step for it. * **sdk, hooks:** Fix two EIP-4361 conformance bugs that made SIWE sign-in fail with a misleading `Invalid SIWE signature` 401. * `buildSiweMessage` now keeps the empty statement slot when no statement is given. The ABNF is `address LF LF [statement] LF LF`, so omitting the blank line produced a message no conforming parser accepts. * `buildSiweMessage` now applies the EIP-55 checksum itself, in any input casing, instead of building a message the server rejects before it ever checks the signature. wagmi users were unaffected because its connectors already normalise; partners bringing their own wallet were not. The SDK stays dependency-free: it carries a self-contained Keccak-256 for this (`toChecksumAddress` is exported for partners who want it directly). **2026-09-02 · v4.7.3** * Add ProphetX to the Venue enum. Discovery only for now, so the venue is in `INACTIVE_VENUES` and hidden by default; it renders with the neutral venue icon until brand artwork lands. Add ProphetX to the event list venue tabs. The tab follows the same per-app gate as every other venue, so it is hidden for any app whose `/app/config` lists prophetx in `disabledVenues`. * **ui:** Fix duplicate cards on the sports market detail page. * Group decomposed N-way legs (halftime result, second-half result, first to score, NRFI) into one card with a pill per leg instead of one card per leg, unioning the venues that offer each leg. A bucket that cannot render as a decomposition splits back to one card per market, so no market is ever hidden. * Collapse whole-number line rungs onto the same ladder as half-step ones. The aggKey encoder writes 8.5 as 8p5 but 8 as a bare integer, so integer rungs used to strand themselves in their own card. * Parse the signed leg token in a spread aggKey. Spread keys now carry m/p on the line, so the previous pattern matched nothing and the signed ladder axis was inert, collapsing both halves of a symmetric spread onto one stop. * Read the favoured and underdog roles off that leg token rather than assuming outcome 0 is always the favourite, which mislabelled every underdog-leg rung. * Head each period section when a tab spans more than one period, so two cards that share a title are distinguishable. * **ui:** Render a sports line ladder as a ladder, never one pill per rung. Every rung of a line ladder is its own Yes/No market with its own question, which by shape alone is indistinguishable from a decomposed N-way leg. A seven-rung team total therefore rendered seven pills on top of the rail that already selects between them. A leg is an outcome of one question and a rung is a line of one market, so a group carrying any lineValue now renders as a ladder and its pills are the active rung's two outcomes. **2026-09-01 · v4.7.2** * Fix limit-order sells on matched (multi-venue) markets. * The sell venue picker no longer offers venues the user holds no shares on. The automatic pick sorts on price alone, so on a matched cluster it could choose a venue with no position — the API then rejected the order with "Insufficient available position size". * The "max shares" figure now matches positions across the whole matched cluster instead of only the selected venue market. Limit mode has no smart-route quote to fall back on, so a position held on another venue of the cluster read as `0 shares`. **2026-09-01 · v4.7.1** * Hide default-disabled venues (Novig today) in the event list tabs while `/app/config` is loading, so a partner-opt-in venue no longer flashes in and out for apps that disable it. **2026-08-31 · v4.7.0** * Market lists for an event now cover its whole matched cluster by default. * `getVenueMarkets({ venueEventId })` and `useVenueMarkets({ venueEventId })` return every venue's markets for that real-world event, not just the markets belonging to the one event you named. Matched siblings are folded into a single anchor row rather than repeated, and each row still carries its cross-venue counterparts in `matchedVenueMarkets[]`. Naming any member of a cluster returns the same page in the same order, so you no longer need to know which event id is the anchor. This is the only way to reach markets that trade on a single venue and therefore have no cross-venue counterpart — previously they were unreachable unless you happened to ask for the exact event that carried them. * New `cluster` option on both, plus `getVenueEventById` and `useVenueEvent`. Pass `cluster: false` for the previous behaviour: only the named event's own markets. If you render a market list from an event's `id` and rely on it matching that one venue's markets exactly, pass `cluster: false` to keep today's output. * `marketCount` and the rolled-up `volume` on `getVenueEventById` / `useVenueEvent` now cover the whole cluster to match. `marketCount` is the number of rows the market list returns for that event (open markets, matched siblings folded into their anchor), so it can be paged to exactly — pass `status: ["open"]` to the market list to see the same set, since an unfiltered list also returns closed and resolved markets. `volume` sums every open and closed market in the cluster, including the siblings folded out of the list, so it is deliberately not a total over those rows. The embedded (deprecated) `venueMarkets` array is unchanged — always the one event's own markets. Pass the same `cluster` value to the event and market calls so the count agrees with the list you render. **2026-08-29 · v4.6.0** * Self-custody fills take a new optional `approveMode` on `fillSelfCustody()`, for wallets that cannot sign a raw-digest EIP-7702 authorization. Omit it (the default, `"sponsored"`) and nothing changes: the wallet is not prompted for gas for the ERC-20 approve, and your signer may be asked for the `eip7702_authorization` request as before — an EOA that is already delegated skips that step. Pass `"user_broadcast"` when you know your wallet cannot produce that signature. The approve comes back as a `transaction` request instead — the user sends it themselves and pays its gas. Only your client can make this call, because whether the signature is possible is a property of the wallet software, not of the address or the chain, so the API cannot detect it. Worth surfacing in your UI: `"user_broadcast"` never establishes the delegation, so the wallet is asked to broadcast an approve and pay its gas on **every** bridge, while the default asks for one authorization signature per chain. Reach for `"user_broadcast"` as a fallback, not a default. * Self-custody users can now fund a trade from their own wallet, not just from money already sitting in their Polymarket deposit wallet. `SignerFn` may now return `{ txHash: string }` as well as a signature string. Returning a string keeps working exactly as before — this is additive, and existing signers need no change. The object form is for the new `transaction` requests, where the wallet broadcasts and you hand back the hash. Return it **immediately, without awaiting the receipt**; the server watches for the mined receipt itself. `SignatureRequestType` gains `"eip7702_authorization"`. Its payload is `{ contractAddress, chainId, nonce }` and it is signed over a raw EIP-7702 digest — not EIP-191, not EIP-712. Many wallets do not expose this. Throwing from your signer is not a fallback — nothing reads it as a rejection, so the request expires and the fill dies. If your wallet cannot sign it, send `approveMode: "user_broadcast"` on the fill and the ERC-20 approve is requested as a `transaction` the user broadcasts themselves. With the authorization signed, the wallet is not prompted for gas on the funding step. * Add a `signer` option and `fillSelfCustody()` for self-custodial fills. Supply a `signer` function and the wallet that will sign, and the SDK drives the sign-and-resume round trip; the key never leaves your process. **2026-08-29 · v4.5.0** * Expose wallet account linking (SIWE/SIWS), matching the API that shipped in `feat(auth): link an external wallet to an existing account`. * `AggLinkAccountBody` gains `{ provider: "siwe" | "siws"; address: string }`, and `AggLinkAccountResult` gains `{ type: "wallet_challenge"; message: string }`. * `linkAccountConfirm()` / `useLinkAccount().confirmLink()` now accept either the existing `link_confirm_token` string (OAuth/email) or `{ kind: "wallet_signature"; message; signature }` (wallet). Pass the `message` returned by `/start` **verbatim** — the server compares it byte-for-byte, so any client-side reconstruction fails. Both are additive and backward-compatible; existing callers are unaffected. **2026-08-28 · v4.4.0** * **hooks:** Add `useSportsLive` and `useRecurringCryptoMarkets`, and harden execution progress reporting. * `useSportsLive` polls live sports scores and exposes `SportsLiveState` with participant scores and game status. Ships `sportsLiveQueryKey`, `sportsLiveRefetchInterval` and the `SPORTS_LIVE_POLL_INTERVAL_MS` / `SPORTS_SCHEDULED_POLL_INTERVAL_MS` constants so callers can align their own cache with the poll cadence. * `useRecurringCryptoMarkets` resolves the rolling window of a recurring crypto market — current instance, timeline and identity — alongside `fetchRecurringCryptoMarkets`, `mergeRecurringCryptoWindows`, `resolveRecurringCryptoIdentity`, `resolveRecurringCryptoTimeline` and `recurringCryptoTransitionPollInterval` for callers driving their own polling. * Execution progress recovers from failed execution steps more predictably, and now exports the `ExecutionStepProgress` and `ExecutionRawStepProgress` types. * Limit orders accept fractional sizes, and venue quotes surface a slippage warning. * Novig added to the exhaustive frontend venue maps. * New `sportsLive` and recurring-market label keys, all defaulted. `AggUiLabelsInput` remains a deep partial, so existing label overrides are unaffected. * **ui:** Add live sports scores and recurring-market navigation to event views, and fix a batch of order-flow issues. * New sports live-score display, exported from `@agg-build/ui/events`, showing participant scores and game status on event items and market details. * Event item details gain recurring-market navigation — window selector, countdown and a "go to live" affordance — driven by the new optional `recurringMarkets` prop. * Order-placement fixes from QA, covering state clearing between orders and slippage presentation in the place-order flow. * Balance management fixes, including raising the default refill maximum to 1000 USDC. * Live scores removed from the event list; they remain on detail views. No exports were removed and no existing prop became required. **2026-08-28 · v4.3.0** * Add `placeOrder()` for direct single-venue order entry via `POST /execution/orders`, and surface the partner `externalId` on order reads, webhooks and order events. * Kalshi is now a fully active venue. `isVenueActive("kalshi")` returns `true`, `INACTIVE_VENUES` is empty, and `getEffectiveDisabledVenues()` no longer adds Kalshi to the disabled set by default. Apps that relied on Kalshi being filtered out automatically should add it to their own `disabledVenues` if they still want it hidden. `isVenueExecutable` has been removed. Every active venue is executable, so `isVenueActive` is the single predicate — replace any `isVenueExecutable(v, override)` call with `isVenueActive(v)`. It was never exported from the partner-facing entry point, so partner code is unaffected. * `getEffectiveDisabledVenues` now treats an app's venue list as authoritative instead of merging it with the platform's default-hidden set. Previously the two were unioned, so the result always included every not-yet-launched venue no matter what the app passed. Now: * `getEffectiveDisabledVenues(undefined)` — no app context — returns the platform defaults, as before. * `getEffectiveDisabledVenues([])` returns `[]`. An app that disables nothing excludes nothing. * `getEffectiveDisabledVenues(["kalshi"])` returns `["kalshi"]`, not `["kalshi", ...defaults]`. The distinction between `undefined` and `[]` is new and load-bearing: `undefined` means "no app configuration exists", `[]` means "this app has opted into everything". Passing `?? []` where you mean "no context" will now reveal default-hidden venues, so pass the value through unchanged. This matters because venue visibility is moving from a compile-time constant to per-app configuration. Read your effective list from `/app/config` (`disabledVenues`), which already resolves this for you — that is unchanged and remains the recommended source. `isVenueActive`, `isVenueInactive`, `INACTIVE_VENUES` and `ACTIVE_VENUES` are unchanged. * Add `deployerVenue` to the orderbook batch types. `MatchedOrderbookMarket.deployerVenue` names which Hyperliquid HIP-4 deployer a cluster member belongs to, and `VenueOrderbookEntry.deployerVenue` names which deployer's book the published entry carries — `venueOrderbooks` is keyed by bare venue name, so it holds at most one Hyperliquid book even when a cluster has several deployer members. Both are `null` for every other venue. Mirrors fields already present on the API contract and the published OpenAPI spec. **2026-08-18 · v4.2.0** * Add Novig to the canonical event-list venue tabs, and stop inactive-venue tabs flashing while `/app/config` loads. `getDefaultEventListTabs` now emits a Novig tab. It renders only where Novig is data-visible — the venue sits in `INACTIVE_VENUES`, so `/app/config` reports it under `disabledVenues` until Novig is enabled, and `useEventListTabs` drops disabled venues before render. Until then partners see no change. `useEventListTabs` previously special-cased Kalshi when deciding whether to treat an unresolved app config as hidden. Any compile-time-inactive venue has the same problem — its tab is "available" until the config resolves, so it appears and then vanishes on first paint. The check now uses the shared `isVenueInactive` helper and covers Novig too. **2026-08-17 · v4.1.0** * Export the `QuoteErrorCode` / `QuoteError` types, and add a `quote_min_order_size` code. `/execution/fill` previously rejected a quote minted with `status: "min_order_size_violated"` as the generic `quote_unfillable`, which named neither the cause nor the fix. It now returns `quote_min_order_size`, and the accompanying `message` names the venue minimum. Both the paper and live fill paths derive the code from the cached quote's status. `QuoteErrorCode` was previously unexported, so `AggApiError.code` could only be compared as a bare string. Branch on it with autocomplete instead: ```ts theme={null} if (isAggApiError(err) && err.code === "quote_min_order_size") { // re-quote with a larger amount } ``` Checking `status === "ok"` on the smart-route response before calling `/execution/fill` avoids the round-trip entirely. **2026-08-16 · v4.0.1** * Fix READMEs left stale by v4. Both packages still documented `quoteManaged` / `useQuoteManaged`, which were removed in v4 — the npm pages described methods that no longer exist. Replaced with the `getSmartRoute()` → `executeManaged()` flow (`useSmartRoute` → `useExecuteManaged` in React) and a note explaining the removal. The SDK's "Peer dependencies" section also claimed the package "bundles its own clients for `viem`, `ethers@5`, `@polymarket/clob-client` and `@polymarket/builder-signing-sdk`". That was never accurate — those were declared as runtime dependencies but no code path imported them, and none were bundled. It now states the truth: zero runtime dependencies, `node:crypto` the only imported module, bring your own signer. Also documents `AggApiError` / `isAggApiError`, added in v4 but missing from the exports table. **2026-08-16 · v4.0.0** * **sdk, hooks:** Remove `quoteManaged` and `useQuoteManaged`, along with the `QuoteManagedParams`, `QuoteManagedResponse`, `QuoteManagedStep`, `QuoteManagedSplit` and `UseQuoteManagedOptions` types. Their endpoint, `POST /execution/quote`, does not exist on the API — the call has only ever returned 404, so no working integration can depend on it. Nothing in the SDK, hooks, UI library or demo app used them. To quote and fill a managed trade: ```ts theme={null} const route = await client.getSmartRoute({ venueMarketOutcomeId, tradeSide: "buy", maxSpend: 50, }); await client.executeManaged({ quoteId: route.quoteId }); ``` The React equivalents are `useSmartRoute` and `useExecuteManaged`. * **sdk:** Export `AggApiError` and `isAggApiError`. Every non-2xx API response has always been thrown with `status`, `code`, `retryable` and `errors` attached, but the thrown value was an untyped `Error`, so consumers had no way to reach those fields without matching on message text. The class and type guard are now exported; runtime behaviour is unchanged. * **sdk:** Remove four unused runtime dependencies (`ethers`, `viem`, `@polymarket/clob-client`, `@polymarket/builder-signing-sdk`). None were imported by any code path — the SDK's only external import is `node:crypto`. This cuts roughly 65 MB from a fresh install and removes the corresponding bundler traversal, which was the single largest install-size contributor for React Native consumers. **2026-08-12 · v3.0.0** * **sdk, hooks:** Remove `yesPrice` from `sortBy` on `listVenueMarkets` / `useVenueMarkets`. The cross-event browse sort by price had no consumer and its dedicated index was one of the largest on the table. Markets listed under an event still order by price where the event's structure calls for it, and the `minYesPrice`/`maxYesPrice` filters are unchanged. Callers passing `sortBy: "yesPrice"` should drop the option. **2026-08-11 · v2.9.0** * Add display metadata to execution status steps: `orderIds`, `kind`, `groupId`, `groupStatus`, `label`, and `bridge` details, so clients can render per-leg execution progress without classifying raw step types. * Add `getVenueGeoPolicy()` — returns the per-venue blocked-country lists (ISO 3166-1 alpha-2) from `GET /app/venue-geo-policy`. This is the raw venue policy, not per-user eligibility; keep using the quote's `warnings` array to gate a fill. **2026-08-08 · v2.8.2** * Add the missing `slippage` field to `SmartRouteResponse`. The API has always returned route slippage metrics on the smart-route response (`SmartRouteResponseTB.slippage` in the contract: `vwap`, `refMidpoint`, `slippage`, `slippageBps`), but the SDK's hand-mirrored interface never declared it. `@agg-build/hooks` reads `slippage.slippageBps` to flag thin liquidity, so that code — and its test — could not typecheck against the published type. Purely additive: the field is optional, so no existing consumer changes behaviour. The new `SmartRouteSlippage` interface is exported alongside it. **2026-08-07 · v2.8.1** * Widen `WithdrawalSourceTokenSymbol` to include `USD1`. BNB USD1 (Myriad settlement inventory) can now fund a withdrawal, so it can appear in a withdrawal's `sources[]`. It is a source-only token — `WithdrawTokenSymbol` (the request/destination union) is unchanged, same as `pUSD`. **2026-08-06 · v2.8.0** * Add `config.formatting.formatPrice` so partners can override how prices render. `formatPercent` already governed probability rendering on discovery surfaces, but every cents surface formatted prices with a hardcoded formatter. Partners using a non-percentage odds convention (decimal, fractional, moneyline) ended up with converted probabilities on discovery pages and unconverted cents everywhere else. `formatPrice` receives the same `0..1` probability as `formatPercent` and now governs the order book, event page outcome chips, place-order, and positions. Set both to the same function to apply one odds convention app-wide. The positions view model also honors `formatCurrency`, which it previously ignored. `formatPrice` is optional and unset by default, so every surface keeps its current rendering until a partner opts in. The order book spread and position PnL percentages stay on the built-in formatters by design — neither is a price. **2026-08-03 · v2.7.3** * Scope the order-book view for a selected outcome to that market's own matched set. The label fallback in `collectEligibleVenueOutcomes` no longer merges same-label outcomes from other markets that are on the opposite semantic side: a second same-venue market with a different question (a decomposed per-team sibling leg or a same-family total) and markets of a different framing shape (bare Yes/No leg vs team-titled single) are excluded when the user's exact outcome is known. Fixes the unmatched kalshi moneyline leg's No view showing polymarket rows the router can never fill on, and the crossed book / negative spread produced by merging opposite-side books. **2026-08-02 · v2.7.2** * Event detail page: load every market instead of only the first page of 20. `useVenueMarkets` gains a `fetchAll` option that eagerly fetches remaining cursor pages until exhausted; `MarketDetailsList` and `useEnrichedVenueEvent` now use it with a 100-row page size, so events with many markets (e.g. 80+ open markets) render their full market list instead of a silently truncated one. * **ui:** Sports moneyline card: when a bare Yes/No leg and a cross-venue titled single collide on one aggKey (venue mis-keys a leg with the event base), keep the titled single — it renders one pill per team and carries the cluster — instead of first-wins evicting it. **2026-08-02 · v2.7.1** * Sports game-detail page: fix period tab ordering and add a sport-aware gate so a market never surfaces a nonsense tab for its sport. first\_N\_innings tabs now sort together, numerically by N (5 Innings no longer sorts before 3 Innings). A period family implausible for the events sport (e.g. a Sets period on a baseball event) demotes to the Other tab instead of rendering its own tab, closing the gap left by an upstream Rust classification fix. The gate fails open: unknown or unlisted sports keep every existing tab. **2026-08-01 · v2.7.0** * Sports game-detail tabs are now derived from the game's own markets instead of a fixed, soccer-shaped list. A baseball game renders Game Lines / 5 Innings / Home Runs / Strikeouts rather than Game Lines / Halves / Other. Three tab kinds are derived per game: **Game Lines** (full-game moneyline, spreads and totals, plus team props and specials such as the first-inning-run and extra-innings markets), **one tab per period family that has lines** (1h and 2h collapse into a single Halves tab as before, while first\_5\_innings reads as 5 Innings), and **one tab per player-prop stat**, labelled from the market subtype. Soccer Corners / Goals / Assists / Shots tabs are unchanged and still resolve their labels through the existing cornersTab / goalsTab / assistsTab / shotsTab ui-config keys, so partner overrides keep working. Three new overridable label keys are added: quartersTab, setsTab and playerPropsTab. The Game Lines tab no longer filters to regular time. Period-scoped markets now have their own tabs, and that filter had been hiding the period-scoped game props which belong on Game Lines. * **ui:** Sports moneyline card: drop decomposed per-team legs as a set once their merged single is present, instead of only the legs carrying a match edge. The matcher routinely pairs just some of a game's legs, which left an orphan leg beside the anchor and still rendered the whole matchup question as a pill. * **ui:** Trade panel: stop presenting an unaffordable primary route as a dead end when the same quote carries an executable solo. Solo route cards with their own quoteId no longer inherit the primary's `insufficient_balance` status/error, and the default card selection prefers the executable solo when the primary is blocked on funding — a Kalshi-funded user with no on-chain balance now lands on a submittable Kalshi route instead of a blocking "Insufficient balance" alert. Quotes where nothing is executable still block exactly as before. **2026-08-01 · v2.6.0** * Sports game-detail tabs are now derived from the game's own markets instead of a fixed, soccer-shaped list. A baseball game renders Game Lines / 5 Innings / Home Runs / Strikeouts rather than Game Lines / Halves / Other. Three tab kinds are derived per game: **Game Lines** (full-game moneyline, spreads and totals, plus team props and specials such as the first-inning-run and extra-innings markets), **one tab per period family that has lines** (1h and 2h collapse into a single Halves tab as before, while first\_5\_innings reads as 5 Innings), and **one tab per player-prop stat**, labelled from the market subtype. Soccer Corners / Goals / Assists / Shots tabs are unchanged and still resolve their labels through the existing cornersTab / goalsTab / assistsTab / shotsTab ui-config keys, so partner overrides keep working. Three new overridable label keys are added: quartersTab, setsTab and playerPropsTab. The Game Lines tab no longer filters to regular time. Period-scoped markets now have their own tabs, and that filter had been hiding the period-scoped game props which belong on Game Lines. * **ui:** Sports moneyline card: drop decomposed per-team legs as a set once their merged single is present, instead of only the legs carrying a match edge. The matcher routinely pairs just some of a game's legs, which left an orphan leg beside the anchor and still rendered the whole matchup question as a pill. **2026-07-31 · v2.5.3** * Sports moneyline card: render one pill per team when a venue's decomposed per-team legs and their merged cross-venue single are both in scope. Opening the leg venue's own event (e.g. kalshi's side of an MLB game) previously showed the merged anchor as an extra pill labelled with the whole matchup question. **2026-07-30 · v2.5.2** * Show every quoted venue in Smart Routing, not just the fundable ones. A solo venue whose own funding source was short came back without an executable quote id and was dropped from the comparison list — so when an off-chain venue won the route (which keeps the overall quote `ok`), the alternative venue vanished from the panel entirely. The card now renders with its payout for comparison; the panel's existing Deposit / Add funds CTA remains the funding signal. **2026-07-28 · v2.5.1** * Add `isVenueExecutable` venue predicate (`@agg/common`) for gating per-venue order-execution eligibility, distinct from `isVenueActive` (compile-time) and `isVenueDataVisible` (data display). **2026-07-27** ### Changed * Markets listed under an event are ordered more sensibly. Sets of competing candidates now sort by odds, and events whose legs are labelled by month sort chronologically instead of falling back to a shared settlement date. * Limitless recurring short-window crypto markets now appear as soon as the venue publishes the next slot, ahead of the window opening, instead of showing up part-way through it. They arrive with `status` of `unopened` and only become tradable once the window starts. ### Fixed * Orders and transfers no longer fail with invalid- or insufficient-balance errors in two cases: Limitless positions reported more shares than the wallet could actually sell, and BNB transfers were quoted against a larger amount than the one that landed in the wallet. * Polymarket deposit wallets now resolve to the address the venue actually deployed. Funding and orders were being pointed at a predicted address that no longer exists on chain. * Activity now lists only deposits a user actually made. Settlement and bridge transfers made during trading that arrived in a managed wallet were being reported as incoming deposits. * Polymarket events that were missing some of their markets, and quiet markets whose prices had stopped updating, now stay current. * Markets Polymarket has shelved no longer show as open and tradable, and events that have finished report a `volume24hr` of `0` rather than a frozen figure. * Full settlement rules are returned for every venue on a matched market instead of just one of them, and Limitless grouped markets now carry their own rules text. * Search and orderbook reads stay fast and reliable under heavy load. Both previously slowed to several seconds or failed outright when traffic was high. **2026-07-24 · v2.5.0** * Add `bookQuality` to position responses (`OutcomePosition`, `VenuePositionBalance`). Positions are marked at the price the held size could actually be closed at, by walking the venue's resting book. When a venue's book is degenerate — a handful of contracts of real bid behind a stub ladder — that mark collapses, but `priceSource` still reports `"orderbook"` because the walk did fill. Nothing in the response distinguished that from a healthy fill. `bookQuality` carries the engine's existing `BookQuality` verdict (`healthy` / `wide_spread` / `bid_only` / `ask_only` / `endpoint_stub` / `crossed` / `empty`) for the worst book across the position's legs, so clients can label a low-confidence mark instead of rendering it as a market price. Values are unchanged — this adds a signal, it does not re-mark. Read `priceSource` first: `bookQuality` only qualifies book-derived marks, and a `settled` position reports `empty` because it consults no book. **2026-07-23 · v2.4.9** * Fix Tailwind utilities resolving theme tokens at `:root` instead of at the element. The `@theme` block is now `@theme inline`, so `.bg-agg-primary` & friends emit `var(--agg-color-primary)` directly rather than a `--color-agg-*` alias bound at `:root`. Two consequences of the old form are fixed: * **Dark mode.** `:root` never matches `.dark .agg-root:not(.light)`, so every utility resolved against the light defaults regardless of theme — `.bg-agg-primary` rendered `#536dfe` and `.text-agg-foreground` rendered `#020205` on dark surfaces. * **Consumer theming.** Overrides scoped to `.agg-root` (including Partner Theme Studio's exported CSS) never reached any utility class. Apps that worked around this by re-declaring the palette at `:root` can drop that block. **2026-07-21 · v2.4.8** * Surface auth-callback failures as a toast. Account linking (and sign-in) completes on the OAuth redirect-return page, detached from the UI that started it — so when `linkAccountConfirm` rejects (e.g. HTTP 409 `already_linked_elsewhere` when the provider identity is already linked to a different user) the error only landed silently in `AggAuthProvider`'s error state and the user saw nothing. `AggAuthProvider` now toasts the callback error (when a toast provider is mounted) with a readable message — `already_linked_elsewhere` → "That account is already linked to a different user." — titled for linking vs sign-in based on whether a `link_confirm_token` is present. **2026-07-21 · v2.4.7** * Fix `client.linkAccountConfirm()` 401'ing with "Missing Authorization header". Confirm runs on the OAuth redirect-return page, where the full-page reload has wiped the in-memory access token (tokens are never persisted). The request therefore went out with no `Authorization` header, and `request()`'s 401-refresh-retry is gated on an existing token so it couldn't recover — so account linking failed at the final step. `linkAccountConfirm()` now mints a fresh access token from the refresh cookie/token (deduped) before the request when the in-memory token is absent; if the session is genuinely gone the refresh throws and the caller surfaces it. **2026-07-21 · v2.4.6** * Complete the ProfileModal account-linking flow. The initial fix (#2503) switched ProfileModal's connect buttons to `client.linkAccount()`, but the rest of the round-trip was missing — this finishes it: * **`@agg-build/sdk`** — `client.linkAccount()` now sends `credentials: "include"` in the default bearer delivery mode. Without it the same-origin link-proof cookie the server sets on `/users/me/link-account/start` was dropped on the cross-origin response, so the OAuth link callback rejected every link with `link_proof_invalid`. * **`@agg-build/hooks`** — `handleAuthCallback` (the shared callback handler auto-run by `@agg-build/auth`'s `AggAuthProvider` and `useAggAuthCallback`) now detects the `link_confirm_token` the provider callback returns, calls `client.linkAccountConfirm()`, refreshes the user, and strips the token from the URL. Previously nothing consumed the token and the link silently never persisted. A collision (identity already linked to a different principal) surfaces as the callback error (HTTP 409); `already_linked_same` resolves idempotently. * **`@agg-build/ui`** — email linking (magic link, no redirect) now shows a "check your inbox" success toast so the click has visible feedback. **2026-07-21 · v2.4.5** * Fix ProfileModal social connect/disconnect. Connect now links the provider to the already-signed-in principal via `client.linkAccount()` (`POST /users/me/link-account/start`) instead of starting a fresh sign-in via `startAuth()` (`POST /auth/start`), which could not attach to the current user and caused a competing double-redirect. Disconnect (and connect) calls are now guarded and surface the API error (e.g. "Cannot disconnect the only linked auth provider") through the modal's toast instead of escaping as an unhandled promise rejection with no user feedback. **2026-07-14 · v2.4.4** * Poll the venue-event query while a market lingers "open" past its endDate, so short-cadence markets (e.g. Kalshi 15-min BTC) transition to their resolved state instead of rendering a stale open market with no orderbooks (SNA-3047). endDate only triggers refetches — server status stays authoritative, so venues with inaccurate endDates can't disable trading. **2026-07-13 · v2.4.3** * Update settlement differences UI behavior **2026-07-11 · v2.4.2** * Stop the combined-moneyline card from rendering unmerged cross-venue duplicates of one market as two identical pills ("Match Winner" ×2 showing the same Yes price twice). When per-leg labels aren't distinct, the card falls back to the standard single-market rendering with team-title pills (SNA-3028, esports). **2026-07-10 · v2.4.1** * Render "Team to Advance" in the Game Lines tab, above the Moneyline (Polymarket parity). Knockout advance markets are primary game lines (multi-million volume) and were buried in Other; for a venue whose only market is the advance single (hyperliquid) this is what makes Game Lines exist at all. One card per game, anchored by the 2-outcome single with team-name pills (SNA-3028). **2026-07-10 · v2.4.0** * The Search primitive now requests `status: ["open"]` so open-only filtering happens server-side: pages arrive full of tradeable results and pagination is honest, instead of fetching all statuses and emptying resolved-heavy pages client-side (which rendered "No markets found" for queries like "5m btc" whose top results are recurring-series history). **2026-07-10 · v2.3.0** * **sdk, hooks:** Add an optional `status` filter to search: `client.search({ status: ["open"] })`, `useSearch({ status: ["open"] })`, and `useMarketSearch({ status: ["open"] })` return only markets/events in the given statuses. Pass `["open"]` for tradeable-only results — without it, recurring series (e.g. 5-minute up/down markets) can fill a page with resolved history, which a UI that hides non-open rows renders as an empty result. * **ui:** Give whole-match totals a clean unit-based title ("Total Goals" / "Total Corners" / "Total Rounds") derived from the group's stat, instead of a raw venue question that produced labels like "Reg Time: Over 0.5 goals scored Totals" when a kalshi market anchored the group (SNA-3028). Per-team totals keep their subject ("Argentina Totals"). **2026-07-10 · v2.2.4** * Show consistent team names on sports spread outcome pills (`{Favored} −X` / `{Dog} +X`) regardless of which venue anchors the ladder stop. A kalshi/limitless anchor stores `Yes`/`No` labels; the team names are now borrowed from the cross-venue twins (poly/predict) at the same signed line, falling back to `Yes`/`No` only when no venue carries a team title (SNA-3028). **2026-07-10 · v2.2.3** * Raise the sports event per-tab market fetch page size from 20 to 100 (the API max), so a prop-heavy tab like "Other" loads in 1–3 requests instead of \~15. * Render sports spreads as a single symmetric ladder (home ← center → away) like Polymarket, instead of two same-titled "Spreads" cards. Both favored-side families (`…_sp_arg` / `…_sp_che`) now collapse into one spread group, and the ladder lays them on a signed magnitude axis with the home/first team anchoring the negative side (SNA-3028). **2026-07-10 · v2.2.2** * Group sports spread/total ladders by the aggKey line-family instead of the market question, so the same total (e.g. match total O/U) no longer fragments into separate cards across venues that phrase it differently (SNA-3028). **2026-07-08 · v2.2.1** * Clarify the id vocabulary across the WS surface and surface resolved-market status on `/midpoints`. * `useMarketOrderbook` now accepts `venueMarketOutcomeId` (the `outcomes[].id` from REST market responses); the old `marketId` option keeps working but is deprecated — it has always carried a venueMarketOutcomeId, not a venueMarketId. * SDK `onSnapshot`/`onDelta` callback parameters are renamed `outcomeId` (type-level only, no runtime change). * `subscribed`/`unsubscribed` confirmations on the arb channel now also echo the ids under `marketIds` (arb subscriptions are keyed by marketIds); the misleading `outcomeIds` mirror stays for backwards compatibility. * The gateway's `Snapshot unavailable for ` error now explains that orderbook subscriptions take venueMarketOutcomeIds, not venueMarketIds. * `GET /midpoints` entries now carry `marketStatus`, and resolved markets return null prices plus a structured `error` (`code: "market_resolved"`) instead of a stale settled-market mark; unknown ids get `code: "market_not_found"`. **2026-07-07 · v2.2.0** * Discovery responses no longer carry outcome prices. `GET /venue-events`, `GET /venue-events/:id`, and `GET /venue-markets` (and `/search`, which shares the same shapes) stop returning `venueMarketOutcomes[].price` — including on `matchedVenueMarkets[]` siblings. The `VenueMarketOutcome.price` type is now optional and deprecated. Fetch live prices from `GET /midpoints` (or the `useMidpoints` / `useViewportMidpoints` / live WS hooks), keyed by `venueMarketOutcomeId`. UI components already prefer live midpoints; the static `price` was only a pre-live seed and is now ignored when absent. **2026-07-05 · v2.1.5** * Sports detail page: honor the documented open period grammar (`set_N`, `map_N`, `game_N`, `period_N`, `inning_N`, `first_N_innings`) instead of collapsing those markets into the "Other" period bucket; treat `match` as full-game scope so whole-match markets stay in Game Lines; stop parsing the opaque `aggKey` for the league label (categories are the only label source). **2026-07-03 · v2.1.4** * Fold cross-venue moneyline legs into the 3-way Moneyline card regardless of which venue anchors the merged representative: `isBinarySportsMoneylineMarket` now also recognizes a leg by its deterministic `_ml_` aggKey (e.g. a kalshi-anchored "Reg Time: Brazil" with `marketSubtype=null`), so no leg drops out as a standalone row. Mis-tagged props stay excluded (they are never `_ml_`-keyed). **2026-07-02 · v2.1.3** * Add a `context: "list" | "detail"` option to `getVenueMarkets` / `useVenueMarkets` and pass `context: "list"` from the event tile's market-scroll query. On list/tile surfaces, axis and dates events now sort their markets by lifetime volume (biggest market first) for both the embedded top-3 and the scroll-loaded tail, while the detail page keeps its structured order (threshold/date ascending). `candidate`/`sport` events are unchanged. **2026-06-23 · v2.1.2** * Improve live trading and resolved-market handling across the AGG React packages. * `@agg-build/hooks` * invalidate and optimistically refresh cached market, balance, position, and activity data when `market_resolved` WebSocket events arrive * export venue-attributed live best-price candidate helpers/hooks so UI surfaces can keep the displayed venue logo aligned with the freshest live price * `@agg-build/ui` * add shared claim-winnings and resolved-market claim hooks for resolved positions * show the new resolved-market earnings/claim state in the event market flow * add `onBeforePrimaryAction` so partners can run a pre-submit gate before normal order execution * keep market detail outcome prices and venue logos in sync with live best-price updates * `@agg-build/sdk` * no direct SDK source or API changes landed in this push * patch bump only to keep the linked public package versions aligned for release/publishing with the updated hooks and UI peers **2026-06-19 · v2.1.1** * Add provider-level AGG UI trading execution mode config. `AggProvider` now accepts `config.trading.executionMode` so hosted UI surfaces can share a global live or paper trading mode without threading `executionMode` through every page or order panel. `PlaceOrder`, `HomePage`, `EventMarketPage`, and `UserProfilePage` use the configured mode by default while preserving explicit component-level overrides. The shared balance provider now also uses the configured paper mode, so `ConnectButton` header totals and `UserProfilePage` balance/positions value cards read paper balances instead of falling back to live-mode zeroes. Paper balances are labeled as Paper in the user profile network breakdown, use a shared paper-mode icon, and show a tooltip warning when the app is running with simulated paper funds. **2026-06-17 · v2.1.0** * Add safe midpoint batching for component-driven market pages. `AggClient.getMidpoints` now splits large `/midpoints` GET requests into smaller batches and merges the responses, defaulting to 75 venue market IDs per request to stay below common CDN URL limits. React hooks and UI components read the same default through `config.market.maxMidpointIdsPerRequest`, and direct hook/SDK callers can override the cap when needed. **2026-06-12 · v2.0.0** * **sdk, hooks:** Narrow recurrence filters to the documented public cadence enum and expose the full supported venue-event cadence list through `RECURRENCE_CADENCES`, including `PT10M`, `P1M`, and `P1Y`. Migration guidance: replace dynamic recurrence-catalog calls with the SDK `RECURRENCE_CADENCES` export or the OpenAPI enum. Pass the literal `"null"` only when filtering one-off venue events. `listRecurringCryptoMarkets` remains limited to deterministic crypto durations: `PT5M`, `PT15M`, `PT1H`, and `P1D`. **2026-06-12 · v1.3.0** * Frontend feature round-up since the last release: live arbitrage signals, nested category navigation, event-tile price gaps, paper-trading mode, and position/redeem fixes. * **Live arbitrage (`@agg-build/sdk`, `@agg-build/hooks`, `@agg-build/ui`)** * SDK added `subscribeArb` and `subscribeArbFeed`, with the supporting wire types (`WsArbMarketUpdate`, `WsArbFeedBatch`, `WsArbFeedEntry`), socket dispatch, reconnect, and refcount handling, and documented the new arb actions in the `WsClientMessage` union. * Hooks added `useMarketArb` (live per-market arb) and `useArbFeed` (event-level MAX rollup), exported with their `UseMarketArbResult` / `UseArbFeedResult` types. * UI brought back the arbitrage sort option and added event-list filters; event tiles and lists now surface arb-driven ordering. * **Nested categories (`@agg-build/hooks`, `@agg-build/ui`)** * Hooks added `useCategoryChildren` (with `UseCategoryChildrenOptions`) for fetching subcategories. * UI renders the category `displayName` on home tabs and event-list filters, supports nested subcategories, and `EventListProps` gained `onCategoryRootChange`. `HomePageCategory` now carries an optional `displayName`. * **Event-tile price gaps (`@agg-build/hooks`, `@agg-build/ui`)** * Hooks exported the `computePriceGaps` helper plus `MAX_PRICE_GAP_PCT` / `MIN_PRICE_GAP_PCT` and the `ComputePriceGapsOptions` / `PriceGapValue` types. * A new `enablePriceGap` feature flag and `priceGap` label gate compact price-gap signals on aggregated event tiles; tiles wait for midpoints before computing gaps to avoid flicker. * **Paper-trading mode (`@agg-build/sdk`, `@agg-build/hooks`, `@agg-build/ui`)** * SDK introduced the `ExecutionMode` (`"live" | "paper"`) type and threaded an optional `mode` through `ExecuteManagedParams`, smart-route params, positions/orders queries, and `ManagedBalancesParams`. * UI's place-order panel accepts an `executionMode` prop and reports the chosen `mode` back through `onPrimaryAction`. * **Positions, redeem, and claim fixes (`@agg-build/hooks`, `@agg-build/ui`)** * Hooks exported `invalidateUserClaimState` and now walk the active bucket when counting redeem-eligible positions; auto-settled HL winners are reported as redeemed rather than ineligible. * UI user-profile tabs trust server-provided position buckets, redeem payout renders from the 6-decimal `totalPayoutRaw` (no longer derived from `legs[0]` decimals), and a new partner-overridable `claim` label group powers claim lifecycle notifications. * **Withdrawals & misc (`@agg-build/sdk`, `@agg-build/ui`)** * SDK added the `WithdrawalSourceTokenSymbol` type to support pUSD withdrawal sources; `WithdrawManagedSourceItem.tokenSymbol` now accepts `pUSD`. * UI event-list skeletons can render the discovery sidebar (`withSidebar`), with refreshed event-tile coloring, volume sort, and updated `sort-volume-24hr` / `shield-trust` icons. **2026-06-05 · v1.2.13** * Add `email: string | null` to the typed `accounts.created` webhook event payload so partners can read email addresses supplied by magic-link or OAuth sign-in. **2026-05-26 · v1.2.12** * Expand the trading labels surface with 9 new partner-overridable keys for execution failure messaging (`sellNoOnchainPosition`, `venueQuoteUnfillable`, `venueMinOrderSize`, `sourceBalanceChanged`, `venueMarketResolved`, `dflowWalletUnverified`, `venueNoQuotePath`, `solanaBlockhashExpired`, `venueRateLimited`, `serviceBlockedOrder`), and wire matching patterns into `normalizePlaceOrderErrorMessage` so the SDK renders friendly copy for every `UserActionableStepError` reason code the API now emits. Also rewrites the venue-terminated pattern coverage to handle Limitless's `FAILED` / `UNMATCHED` settlement statuses with dedicated friendly copy (`"Order didn't settle on-chain"` and `"No liquidity matched your order at the quoted price"`), and extends the min-order-size pattern to recognize predict.fun's existing `predict_min_quantity` user-actionable copy. * Centralize execution failure labels and clean up place-order error normalization for venue outages and expired submission deadlines. **2026-05-21 · v1.2.11** * Deposit & withdraw UX overhaul: * New `useWithdrawEstimate` hook surfaces per-route fee and ETA estimates before submit. * Refreshed deposit and withdraw flows, including new amount/method/success screens, transfer fee summary, and friendly error states. * `WithdrawManagedResponse` now exposes `completedAmountRaw`, and the WS `WithdrawalLifecycleEvent` includes `requestedAmountRaw` and `completedAmountRaw` so consumers can render the terminal settled amount. * New balance/activity invalidation helpers (`invalidateUserActivityQueries`, `invalidateUserMoneyState`, `userActivityQueryKeys`) exported from `@agg-build/hooks`. * User profile activity rows and balance cards updated with refined states; new `spinner` and `circle-xmark` icons; tooltip primitive tweaks. **2026-05-07 · v1.2.10** * Fix a bug in geo-blocking behavior for order splitting **2026-05-07 · v1.2.9** * UI updates for Positions tab and removed hardcoded colors **2026-05-06 · v1.2.8** * Added new prop in tooltip for gap and UI adjustments **2026-05-06 · v1.2.7** * Fix a bug in geo-blocking behavior for order splitting **2026-05-06 · v1.2.6** * UI updates in the withdraw modal and claim flow for design QA **2026-05-05 · v1.2.5** * Bug fix for nullable venueMarketOutcomeId **2026-05-05 · v1.2.4** * Bug fix for nullable venueMarketOutcomeId * Update network chain logos to prediction-markets CDN paths; fix Solana missing from deposit modal network list **2026-05-05 · v1.2.3** * removed redundant div from venue selection **2026-05-04 · v1.2.2** * Sorting user positions **2026-05-04 · v1.2.1** * Market details UX updates **2026-05-04 · v1.2.0** * Added smart-route fee breakdown support across AGG packages. * `@agg-build/sdk` now includes optional `feeBreakdown` data on smart-route quote responses so integrators can display venue fees, bridge fees, execution gas, and total fees. * `@agg-build/hooks` adds `features.showFeesBreakdown` to UI config and new label keys for fees and updated positions table/status copy. * `@agg-build/ui` adds optional fee breakdown display in the place-order flow, updates route ordering to prioritize better executable value, improves mobile event-market trade opening from outcome selection, and upgrades positions UX (active/closed filtering, status/result badges, and claim-state handling). These changes are backward compatible and require no migration for existing integrations unless you want to enable the new fee breakdown UI (`showFeesBreakdown`). **2026-04-30 · v1.1.0** * Added xs size for icons and geo block updates for order **2026-04-30 · v1.0.2** * Refresh published package metadata and fixes for the AGG SDK package stack. Updates: * @agg-build/hooks: fix midpoint price calculation in `useViewportMidpoints`; minor adjustments to UI-config labels and the `useWithdrawFlow` hook. * @agg-build/ui: settlement formatting refactor (new `settlement-text-renderer` + `settlement-text.utils`); `PlaceOrder` flow updates including default amount, sell-tab default, partially-filled state, best-price wiring, max label, and a sign-in-to-trade flow; refreshed typography variants; new `apps` and `circle-overlap` icons; custom `Select` migration for TBAs; connect-button view rewrite; event-list search/filter improvements. * @agg-build/auth: updates to the SIWE adapter and shared auth utils. **2026-04-28 · v1.0.1** * **sdk**: Add withdrawal lifecycle WebSocket support — new `WsWithdrawalLifecycleEvent` message type, `onWithdrawalLifecycle` SDK callback, and associated types (`WsWithdrawalLifecycleStatus`, `WsWithdrawalLegStatus`, `WsWithdrawalLifecycleLeg`). **hooks**: Add `useWithdrawFlow`, `useWithdrawalLifecycle`, and `useOnWithdrawalLifecycle` hooks with full withdrawal lifecycle tracking via WebSocket. Fix live outcome price fallback so REST midpoints surface when no WS midpoint exists. Improve `useMidpoints` to prefer the venue with the lower YES midpoint for better buyer pricing. Fix hooks package exports to use format-specific type declarations (`.d.mts` for ESM consumers). **ui**: Add wallet withdrawal flow — recipient address input, amount/token/network selection, confirmation step, and an order-style execution timeline showing per-leg bridge/transfer progress with live lifecycle status. Add `CopyButton` primitive. Show wallet address on card-payment pending step in the deposit flow. Surface best-price venue indicators in the event list, market details, and orderbook. Fix cluster-aware market deduplication to collapse cross-page duplicates. **2026-04-27 · v1.0.0** * Initial v1.0.0 release of the AGG client libraries on npm under the `@agg-build` scope. # Customize UI Source: https://docs.agg.market/components/customization Theme AGG UI components with CSS variables, next/font, labels, formatting, and component slots # Customize AGG UI AGG UI is designed to be embedded in partner apps without forking components. The supported customization surface is: * CSS variables on the AGG root for brand, typography, radius, shadow, and z-index tokens * `AggProvider` config for theme mode, labels, formatting, feature flags, search wiring, and wallet actions * component `className` / `classNames` props for layout composition > [!NOTE] > > Import `@agg-build/ui/styles.css` once, then load your app CSS after it. AGG defaults are low > specificity, so `:root` overrides can work, and `.agg-root` overrides win because they are closer > to the AGG UI tree. ## Minimal integration ```tsx app/providers.tsx theme={null} import "@agg-build/ui/styles.css"; import "./agg-theme.css"; import { QueryClient, QueryClientProvider } from "@agg-build/hooks"; import { createAggClient } from "@agg-build/sdk"; import { AggProvider } from "@agg-build/ui"; import type { ReactNode } from "react"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", wsUrl: "wss://ws.agg.market/ws", }); const queryClient = new QueryClient(); export function Providers({ children }: { children: ReactNode }) { return ( {children} ); } ``` ```tsx app/page.tsx theme={null} import { HomePage } from "@agg-build/ui/pages"; export default function Page() { return ; } ``` `AggProvider` renders an `.agg-root` wrapper around its children. If you pass `general.rootClassName`, that class is added to the same root so you can scope partner-specific branding without affecting other AGG embeds on the page. ## Import order Load AGG styles first and partner overrides second: ```tsx theme={null} import "@agg-build/ui/styles.css"; import "./agg-theme.css"; ``` Then put variables in your CSS: ```css agg-theme.css theme={null} .agg-root.acme-agg-theme { --agg-color-primary: #2563eb; --agg-color-primary-hover: #1d4ed8; --agg-color-secondary: #ffffff; --agg-color-secondary-hover: #f1f5f9; --agg-color-border: #cbd5e1; --agg-color-foreground: #0f172a; --agg-color-muted-foreground: #475569; --agg-color-on-primary: #ffffff; --agg-radius-md: 0.625rem; --agg-radius-full: 0.75rem; --agg-shadow-card: 0 8px 20px rgb(15 23 42 / 0.08); } ``` ## Custom fonts Set the AGG font token on `.agg-root`. This affects AGG typography utilities, primitives, pages, modals, and portaled surfaces. ### Plain CSS ```css theme={null} .agg-root { --agg-font-family-sans: "Your Font", ui-sans-serif, system-ui, sans-serif; } ``` You can also set the token on `:root`; a value on `.agg-root` wins if both exist. ### Next.js `next/font` Expose the generated font variable on an ancestor, then map it into the AGG token. ```tsx app/layout.tsx theme={null} import { Inter } from "next/font/google"; import { Providers } from "./providers"; import "./globals.css"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter", }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```css globals.css theme={null} .agg-root { --agg-font-family-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; } ``` ## Light and dark themes `general.theme` controls the class and data attributes applied to `.agg-root`. If you ship custom dark tokens, match AGG's dark selector shape so your values win in dark mode: ```css theme={null} .dark .agg-root.acme-agg-theme:not(.light), .agg-dark .agg-root.acme-agg-theme:not(.light), .agg-root.acme-agg-theme.dark, .agg-root.acme-agg-theme[data-agg-theme="dark"] { --agg-color-primary: #8fb4ff; --agg-color-primary-hover: #b4ccff; --agg-color-secondary: #0b1220; --agg-color-secondary-hover: #172036; --agg-color-border: #334155; --agg-color-foreground: #e2e8f0; --agg-color-muted-foreground: #94a3b8; --agg-color-on-primary: #121214; --agg-shadow-card: 0 10px 28px rgb(2 6 23 / 0.45); } ``` ## HomePage discovery URL persistence `features.enableEventDiscoveryUrlPersistence` enables browser-history persistence for the primary `HomePage` section's category, subcategory path, venue, and sort filters. Defaults are omitted from the URL, unrelated query parameters are preserved, and the flag is disabled by default. ```tsx theme={null} ``` ## Gradients Keep semantic colors and gradient definitions separate. `features.enableGradients` controls whether branded surfaces resolve to flat primary colors or gradient tokens. ```tsx theme={null} ``` ```css theme={null} .agg-root.acme-agg-theme { --agg-gradient-brand: linear-gradient(90deg, #2563eb, #4f46e5); --agg-gradient-brand-hover: linear-gradient(90deg, #1d4ed8, #4338ca); } .dark .agg-root.acme-agg-theme:not(.light), .agg-dark .agg-root.acme-agg-theme:not(.light), .agg-root.acme-agg-theme.dark, .agg-root.acme-agg-theme[data-agg-theme="dark"] { --agg-gradient-brand: linear-gradient(90deg, #8fb4ff, #c084fc); --agg-gradient-brand-hover: linear-gradient(90deg, #b4ccff, #d8b4fe); } ``` ## Public theme tokens Set these on `.agg-root` or `.agg-root.`. | Token | Usage | | - | - | | `--agg-color-primary` | Primary CTA color and emphasis states | | `--agg-color-primary-hover` | Primary action hover color | | `--agg-color-secondary` | Card, popover, modal, and control surfaces | | `--agg-color-secondary-hover` | Neutral hover and selected surfaces | | `--agg-color-border` | Borders and separators | | `--agg-color-foreground` | Primary text and icon foreground | | `--agg-color-muted-foreground` | Secondary text, metadata, and helper labels | | `--agg-color-on-primary` | Text and icons on branded filled surfaces | | `--agg-color-success` | Positive status and bid-side color | | `--agg-color-warning` | Warning status | | `--agg-color-error` | Error status and ask-side color | | `--agg-color-overlay` | Modal and drawer overlay color | | `--agg-gradient-brand` | Branded gradient fill | | `--agg-gradient-brand-hover` | Branded gradient hover fill | | `--agg-font-family-sans` | Base AGG UI font family | | `--agg-font-size-2xs` through `--agg-font-size-4xl` | Typography size scale | | `--agg-line-height-3`, `--agg-line-height-14`, `--agg-line-height-4` through `--agg-line-height-10`, `--agg-line-height-base` | Typography line-height scale | | `--agg-font-weight-normal`, `--agg-font-weight-medium`, `--agg-font-weight-semibold`, `--agg-font-weight-bold` | Typography weight scale | | `--agg-radius-sm`, `--agg-radius-md`, `--agg-radius-lg`, `--agg-radius-xl`, `--agg-radius-2xl`, `--agg-radius-full` | Radius scale | | `--agg-shadow-card`, `--agg-shadow-popover`, `--agg-shadow-modal` | Elevation tokens | | `--agg-z-index-dropdown`, `--agg-z-index-overlay`, `--agg-z-index-modal`, `--agg-z-index-toast`, `--agg-z-index-tooltip` | Dropdown, overlay, modal, toast, and tooltip layering | Avoid overriding bridge tokens such as `--color-agg-*`, `--text-agg-*`, and `--font-agg-sans` directly. They are generated from the public `--agg-*` tokens. ## Copy and labels Use `general.labels` when you need to change user-facing text. Label overrides are deep-merged with AGG defaults, so you only provide the keys you want to replace. ```tsx theme={null} ``` Use labels for product copy. Use CSS variables for visual styling. Use component props for data, callbacks, and layout. ## Formatting Override formatting functions when your app needs a different locale, currency presentation, or date style. ```tsx theme={null} const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, }); usdCompact.format(value), }, }} > ; ``` ### Odds display `formatPercent` and `formatPrice` are two renderings of the same number — both receive a probability between `0` and `1`. Discovery surfaces (event tiles, search results, charts) use `formatPercent`; price surfaces (order book, event page outcome chips, place-order, positions) use `formatPrice`. To show a single odds convention app-wide, set both to the same function: ```tsx theme={null} // Decimal odds: 0.62 -> "1.61" const toDecimalOdds = (probability: number) => (1 / probability).toFixed(2); ; ``` `formatPrice` is optional. Leave it unset to keep the default cents rendering (`62.5¢`). Two values deliberately stay on the built-in formatters even when `formatPrice` is set, because neither is a price: the order book **spread** (a difference between two prices, always in cents) and position **PnL percentages**. ## Layout and slots Use `className` / `classNames` for layout composition, not deep CSS selectors. ```tsx theme={null} import { HomePage } from "@agg-build/ui/pages"; export function MarketHome() { return ( ); } ``` Prefer token overrides for colors, fonts, radius, shadows, and other repeated visual decisions. Use slot class names for spacing, containment, sticky behavior, or one-off layout requirements. ## Troubleshooting > **My custom font does not apply** > > Make sure `@agg-build/ui/styles.css` is imported before your app CSS, and set > `--agg-font-family-sans` on `.agg-root`. With `next/font`, put the generated font variable on > an ancestor of `AggProvider`. > **My colors work on the page but not in a modal or tooltip** > > Scope variables to `.agg-root` and pass a stable `general.rootClassName` if you have multiple > embeds. AGG portaled surfaces also carry `.agg-root`, so the same scoped token block applies. > **Dark mode falls back to AGG defaults** > > Add a dark-mode override block using AGG's selector shape. Light tokens and dark tokens are > separate. > **My Tailwind classes do not restyle AGG internals** > > AGG internals use AGG token utilities. Override `--agg-*` variables for global theming, and use > documented `className` / `classNames` props for layout slots. ## Related tools > **[Storybook Customization](https://storybook.agg.market/?path=/docs/home-customization--docs)** > > Interactive token guide and live component catalog. > **[Theme Studio](https://storybook.agg.market/?path=/docs/home-partner-theme-studio--docs)** > > Import a URL, inspect extracted brand tokens, and export CSS. > **[Setup Guide](/quickstart/react)** > > Provider setup for SDK, hooks, UI components, and auth. > **[Components Overview](/components/overview)** > > Curated references for pages, trading surfaces, events, and auth UI. # Components Overview Source: https://docs.agg.market/components/overview Pre-built UI components for prediction market data # Components AGG provides ready-to-use React components via the `@agg-build/ui` package. Auth-specific UI lives in `@agg-build/auth`, which composes `@agg-build/ui` primitives with the shared `AggProvider` session state from `@agg-build/hooks`. > [!NOTE] > > Start with the [Setup Guide](/quickstart/react) before dropping components into your app. Most UI and > auth components expect a configured `AggProvider`, and auth flows also need `AggAuthProvider`. > Use [Customize UI](/components/customization) when you are ready to brand colors, fonts, copy, > formatting, and layout slots. ## Browse the component catalog Storybook is the fastest way to browse the full `@agg-build/ui` catalog, compare states, and inspect controls. The docs pages below focus on curated references and implementation guidance for the most important surfaces. > **[Open Storybook](https://storybook.agg.market)** > > Browse the live component catalog, controls, and interaction states. > **[Customize UI](/components/customization)** > > Theme AGG with CSS variables, `next/font`, labels, formatting, and slots. > **[Connect Button](/components/auth/connect-button)** > > Wallet, OAuth, and email auth surface. > **[Event List](/components/events/event-list)** > > Market discovery grids and event browsing. > **[Market Details](/components/events/market-details)** > > Live charts, orderbooks, and venue detail cards. > **[Place Order](/components/trading/place-order)** > > Buy and sell trading surface. > **[Settlement](/components/trading/settlement)** > > Resolution and venue comparison surface. > **[Home Page](/components/pages/home-page)** > > Market discovery landing layout. > **[Event Market Page](/components/pages/event-market-page)** > > Full event page with charting and trading. > **[Leaderboard](/components/pages/leaderboard-page)** > > Trader rankings. The data call is Portfolio. [Leaderboard Page](/components/pages/leaderboard-page) ranks traders. The read is [Portfolio](/recipes/portfolio). ## ConnectButton ```tsx theme={null} import { AggAuthProvider, ConnectButton, createEmailAuthMethod } from "@agg-build/auth"; ; ``` **Requirements:** Must be wrapped in both `` from `@agg-build/hooks` and `` from `@agg-build/auth`. `AggAuthProvider` also handles AGG auth callback query parameters when the component tree mounts on a callback route. See the [Connect Button reference](/components/auth/connect-button) for the live Storybook-backed surface. ## PlaceOrder PlaceOrder takes a view model, it does not fetch, and it does not take `marketId`. `import { PlaceOrder } from "@agg-build/ui/trading"`. ```tsx theme={null} import { PlaceOrder } from "@agg-build/ui/trading"; ``` See the [Place Order reference](/components/trading/place-order) for interactive Storybook-backed previews and state exploration, or open [Storybook](https://storybook.agg.market) to browse the wider component catalog directly. # Leaderboard Page Source: https://docs.agg.market/components/pages/leaderboard-page Interactive preview for the @agg-build/ui LeaderboardPage surface # Leaderboard Page `LeaderboardPage` ranks your app's traders by realized P\&L or volume over a preset period (24h, 7d, 30d, all time). Identity fields (username, avatar, wallet) follow the app's `leaderboardIdentity` setting, which defaults to anonymous. Rankings are `GET /leaderboard`. See [Portfolio](/recipes/portfolio) and [Trader leaderboard](/api-reference/portfolio/trader-leaderboard). Use Storybook as the source of truth for controls, layout states, and composition details. ## Import ```tsx theme={null} import "@agg-build/ui/styles.css"; import { LeaderboardPage } from "@agg-build/ui/pages"; ``` Requires `` from `@agg-build/hooks`. ## Props | Prop | Description | | - | - | | `period`, `onPeriodChange` | Controlled preset: `"1d" \| "7d" \| "30d" \| "all"` | | `metric`, `onMetricChange` | Controlled sort: `"realizedPnl" \| "volume"` | | `rows`, `isLoading`, `error`, `hasNextPage`, `onLoadMore`, `isLoadingMore` | Supply your own data instead of the built-in `useLeaderboard` hook | | `currentUserId` | Row to highlight as "You" (defaults to the signed-in user) | | `appName` | Shown in the subtitle | | `onError` | Called with render errors caught by the page's error boundary | | `className` | Extra classes on the root element | | `classNames` | Slot overrides: `root`, `header`, `toolbar`, `list`, `row`, `footer` | P\&L is realized only (sells and settled markets, before fees) and refreshes every 5 minutes. Each row carries `volume` (buys and sells) and `spent` (buys only), so you can show either. ## Preview Leaderboard Page Open in Storybook Leaderboard Page light # Accounts & wallets Source: https://docs.agg.market/concepts/accounts-and-wallets How a user's sign-in identity, managed wallet, self-custody wallets, and venue accounts relate, and which address to pass where A user signs in with one or more identities. Funds sit in one of several places: a managed wallet AGG signs for, the user's own wallet, or an account on a single venue. This page names each one and links to the page that owns its procedure. ## Sign-in identity vs trading wallet A person has one AGG user per app. That user can have several sign-in identities: Ethereum and Solana wallets, Google, Twitter, Apple, and email. Add more with [Account linking](/recipes/account-linking). One identity belongs to one AGG user; linking it to a second user returns `409`. Signing in does not choose where funds are held. A wallet used to sign in is only a self-custody trading wallet when you pass it as `signingAddress`. Without that, every trade uses the managed account. ## Managed account Each user gets a managed wallet with two addresses: `evmAddress` and `svmAddress`. AGG holds the keys and signs every trade, bridge, and withdrawal. The user does not sign anything. * `evmAddress` receives on every supported EVM chain and on Hyperliquid. * `svmAddress` receives on Solana. * Balances are tracked per chain and per token. A trade can spend from any chain; AGG moves the funds to the venue it routes to. ```ts SDK theme={null} const deposit = await client.getDepositAddresses(); if (deposit.ready) { console.log(deposit.evmAddress, deposit.svmAddress); } ``` ```bash cURL theme={null} curl https://api.agg.market/execution/deposit-addresses \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` [Get deposit addresses](/api-reference/funding/get-deposit-addresses). It returns `{ "ready": false }` until the addresses exist; [Funding & withdrawals](/concepts/funding) owns the poll, balances, and withdrawals. **Privy wallet provider.** When your app uses Privy as its wallet provider, the managed wallet is the user's own Privy wallet instead of one AGG creates. AGG still signs, through the authorization key you saved, with no user present. `evmAddress` is that Privy wallet. See [Privy wallet provider](/recipes/privy-wallet-provider). ## Self-custody The user keeps their own key. Their EVM wallet, an **EOA**, must first be linked to the account with [Account linking](/recipes/account-linking). Quotes and fills name it as `signingAddress`. AGG prices the route against that wallet's balances and asks it to sign each step. A Solana wallet cannot be a `signingAddress`. `fundingAddresses` names other linked EVM wallets the same trade may spend from. The signing wallet still signs the venue order; each funding wallet signs only the transfers of its own money. Self-custody is supported on these venues. Any other venue is refused at fill time. | Venue | Where the funds are | Who signs | | - | - | - | | Polymarket | The EOA, its Polymarket deposit wallet on Polygon, and a legacy Polymarket wallet if one exists | The EOA | | Hyperliquid | The EOA's own Hyperliquid balance | The EOA. Where enabled, an agent named `agg` that the EOA approved may sign orders and cancels. An agent cannot move funds. | | Kalshi | The user's own Kalshi account | AGG, with the user's Kalshi API key from [Venue keys](/recipes/venue-keys). No wallet prompt. | **Polymarket deposit wallet.** Each linked EOA has one Polymarket deposit wallet. Its address is derived from the EOA and stays fixed once AGG records it. Polymarket orders are placed from it. A self-custody Polymarket limit order needs it to exist already. Its pUSD can also fund a Hyperliquid buy: AGG bridges it from Polygon to the EOA's HyperCore balance, and the EOA approves the move with one `eip712` signature. See [Self-custody trading](/recipes/self-custody#funding-sources). **Legacy Polymarket wallets.** Some users have a wallet Polymarket created before AGG, owned by their EOA. AGG finds it and reads its balance. There are two kinds: * **Legacy Safe**: a Gnosis Safe. Its balance can fund a trade on any venue. The EOA approves moving funds out of it with a `safe_tx` signature. * **Legacy proxy**: its balance can only be spent on Polymarket. Nothing moves it to another venue, and self-custody limit orders are refused for a wallet that has one. `GET /execution/balances?custody=self` lists each of these with a `custodyKind` of `EOA`, `DEPOSIT_WALLET`, or `POLYMARKET_LEGACY`, plus `legacyKind`. A `proxy` row still counts in the token's `totalRaw` and `availableRaw`; subtract it to show what can be spent elsewhere. Kalshi cash does not appear in this view. Procedures: [Self-custody trading](/recipes/self-custody) and [Self-custody signing reference](/recipes/self-custody-signing). ## Venue accounts Some balances live inside a single venue and can only be spent there. * **BetDEX hosted account.** AGG creates and runs a BetDEX account for the user. It has its own Solana USDC deposit address, separate from the managed balance. See [Hosted venue accounts](/recipes/hosted-venue-accounts). * **ProphetX and Novig direct accounts.** When your app's execution mode for that venue is `direct`, the user stores their own venue credentials with `PUT /venue-keys` and trades with that account's cash. AGG does not deposit, withdraw, or redeem those funds. See [Market resolution](/concepts/market-resolution). * **Kalshi account.** The user's own Kalshi account and API key, stored with [Venue keys](/recipes/venue-keys). Every Kalshi trade, managed or self-custody, spends that account's cash. See [Kalshi](/venues/kalshi). ## Which mode, at a glance | Mode | Funds held in | Who signs | Address you pass | | - | - | - | - | | Managed | The managed wallet | AGG | None | | Privy wallet provider | The user's Privy wallets | AGG, with your Privy authorization key | None | | Self-custody | The user's EOA and its Polymarket deposit and legacy wallets | The user's wallet | `signingAddress`, optionally `fundingAddresses` | | Kalshi | The user's Kalshi account | AGG, with the user's Kalshi key | None. In a self-custody route, `signingAddress` as usual. | | BetDEX hosted account | A BetDEX account AGG hosts | AGG | None. Deposit to the hosted account's address. | | ProphetX or Novig direct | The user's own venue account | AGG, with the user's venue key | None | Compare the trading flows in [Choose your integration](/integration-options). ## Glossary | Term | Meaning | | - | - | | EOA | An externally owned account: an ordinary EVM wallet controlled by one private key. The user's linked wallet in self-custody. | | Signing address | The linked EOA passed as `signingAddress` on a quote or fill. It signs the venue order. | | Funding address | A linked EVM wallet passed in `fundingAddresses`. It pays part of the trade and signs only its own transfers. | | Deposit address | `evmAddress` or `svmAddress` from `GET /execution/deposit-addresses`, where the user sends funds to the managed account. A hosted BetDEX account has its own. | | Deposit wallet | The Polymarket deposit wallet on Polygon, derived from one EOA. Polymarket orders are placed from it. | | Managed wallet | The wallet AGG holds keys for and signs with. With the Privy wallet provider, it is the user's Privy wallet. | | Legacy Safe | A Gnosis Safe Polymarket created for the user before AGG. Its balance can fund any venue. | | Legacy proxy | An older Polymarket proxy wallet. Its balance can only be spent on Polymarket. | # Authentication & sessions Source: https://docs.agg.market/concepts/authentication How users sign in, what tokens you get back, and how to keep a session alive AGG authenticates two things: your **app**, with the `x-app-id` header, and a **user**, with an access token in `Authorization: Bearer `. Market data and quotes without a user need only the app. Balances, deposits, trading, and withdrawals need a user. See [Base URL, app IDs & API keys](/environments) for which headers go on which call. Users sign in on the client, and your backend includes that user's access token on trading calls. Wallet sign-in (SIWE) is the [Quickstart: REST](/quickstart/rest). ## Sign-in methods | Provider | How it completes | Start with | | - | - | - | | `siwe` (Ethereum wallet) | The wallet signs a message; `POST /auth/verify` returns tokens | `POST /auth/start` returns a `nonce` | | `siws` (Solana wallet) | The wallet signs a Solana message; `POST /auth/verify` returns tokens | `POST /auth/start` returns a `nonce` | | `google`, `twitter`, `apple` | AGG redirects back to your `redirectUrl` with `?code=`; `POST /auth/token/exchange` returns tokens | `POST /auth/start` returns a redirect `url` | | `email` | The user opens a magic link that redirects back with `?code=`; exchange it the same way | `POST /auth/start` sends the email | | `privy` | `POST /auth/verify` with `{ "kind": "privy", "privyToken": "..." }` | No start call | Other providers: [Sign-in providers](/recipes/authentication). ## OAuth, email, and Privy OAuth and email both finish with `POST /auth/token/exchange` using the `code` from the redirect. Exchange it on your callback page or backend. Email sends the link from `POST /auth/start`. Privy skips start and calls `POST /auth/verify`. ```ts SDK theme={null} await client.authStart({ provider: "email", email: "user@example.com", redirectUrl: "https://app.example.com/callback", }); await client.exchangeAuthCode(codeFromRedirect); await client.verify({ kind: "privy", privyToken: "PRIVY_ACCESS_TOKEN" }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/auth/start \ -H "x-app-id: $AGG_APP_ID" -H "content-type: application/json" \ -d '{ "provider": "email", "email": "user@example.com", "redirectUrl": "https://app.example.com/callback" }' curl -X POST https://api.agg.market/auth/token/exchange \ -H "x-app-id: $AGG_APP_ID" -H "content-type: application/json" \ -d '{ "code": "CODE_FROM_REDIRECT" }' curl -X POST https://api.agg.market/auth/verify \ -H "x-app-id: $AGG_APP_ID" -H "content-type: application/json" \ -d '{ "kind": "privy", "privyToken": "PRIVY_ACCESS_TOKEN" }' ``` Email start: [Start authentication](/api-reference/authentication/start-authentication). Code exchange (OAuth and email): [Complete redirect sign-in](/api-reference/authentication/complete-redirect-sign-in). Privy: [Verify sign-in](/api-reference/authentication/verify-sign-in). `redirectUrl` on email and OAuth must be an allowed origin. It is where `?code=` is delivered. Start OAuth in the browser with `POST /auth/start` so the callback can use its proof cookie. ## Tokens | Token | Use | Lifetime | | - | - | - | | `accessToken` | `Authorization: Bearer` on user-tier calls | Short-lived. Refresh it when a call returns `401`. | | `refreshToken` | Body of `POST /auth/token/refresh` | Longer-lived. Replace it with the new one each time you refresh. | The access token is bound to the app that issued it. Using it with another app's `x-app-id` fails. The SDK stores the session and adds the header for you. In the browser, set `authDelivery: "cookie-refresh"` on `createAggClient` to keep the refresh token in an `HttpOnly` cookie scoped to the AGG API host and `/auth` routes. In that mode `verify()` and `exchangeAuthCode()` can omit `refreshToken` from the JSON response. If your backend already holds a user's tokens, seed an SDK client with `client.setSession({ accessToken, refreshToken })`. ## Refresh a session When a user-tier call returns `401`, refresh once and retry. If the refresh also fails, sign the user in again. ```ts SDK theme={null} async function withAutoRefresh(fn: () => Promise): Promise { try { return await fn(); } catch (err: any) { if (err.status === 401 && client.isAuthenticated) { try { await client.refreshAccessToken(); return await fn(); } catch { await client.signOut(); throw new Error("Session expired. Please sign in again."); } } throw err; } } const balances = await withAutoRefresh(() => client.getManagedBalances()); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/auth/token/refresh \ -H "x-app-id: $AGG_APP_ID" -H "content-type: application/json" \ -d "{ \"refreshToken\": \"$REFRESH_TOKEN\" }" ``` [Refresh access token](/api-reference/authentication/refresh-access-token). React to sign-out anywhere in your app: ```ts theme={null} client.onAuthStateChange((authenticated) => { if (!authenticated) router.push("/sign-in"); }); ``` If you also stream authenticated WebSocket events, refresh the REST session first, then re-authenticate or reconnect the socket. See [User notifications](/recipes/websocket-notifications). ## Sign out `POST /auth/sign-out` with the user's token revokes all of that user's tokens. `client.signOut()` also clears the stored session. [Sign out](/api-reference/authentication/sign-out). ## Sign-in errors | Status | Meaning | | - | - | | `401` with `code: "unregistered_domain"` | The wallet message domain is not an allowed origin. | | `401` | Bad signature, reused or expired nonce, or an invalid Privy token. | | `200` with `type: "challenge_required"` on `POST /auth/start` | Turnstile. Resubmit that call with `turnstileToken`. See [Bot protection](/recipes/bot-protection). | | `403` on `POST /auth/start` | The Turnstile token was rejected. | | `403` on `POST /auth/verify` | Testing mode already has 20 users, so a new user is refused. See [Base URL, app IDs & API keys](/environments). | | `429` | Too many sign-in attempts. Wait for `Retry-After`. | OAuth and email report that same 20-user cap on the redirect URL, not as the verify `403`. See [Base URL, app IDs & API keys](/environments). ## Related > **[Sign-in providers](/recipes/authentication)** > > OAuth, email, Privy, and wallet provider setup. > **[Account linking](/recipes/account-linking)** > > Add more sign-in methods or wallets to one user. > **[User avatars](/recipes/avatar-upload)** > > Upload, confirm, and remove a profile image for the signed-in user. # Errors, retries & idempotency Source: https://docs.agg.market/concepts/errors Error shapes, HTTP statuses, quote and fill error codes, and which calls are safe to retry ## Error shape Errors return JSON with a readable `message`. Quote and trading errors can add a stable `code`: ```json theme={null} { "message": "Quote has expired", "code": "quote_not_found" } ``` Branch on `code` when it is present, and always show or log `message`. Some errors carry only a `message`. A few `400` messages start with a stable token such as `app_fee_override_requires_api_key:`; match on that prefix. ## HTTP statuses | Status | Meaning | Retry? | | - | - | - | | `400` | Invalid request, or a quote, fill or order refusal. Read `code`. | Depends on `code`, see below. Never retry unchanged otherwise. | | `401` | Missing or invalid `x-app-id`, API key, or access token. | Refresh the access token once, then retry. See [Authentication & sessions](/concepts/authentication). | | `403` | Not allowed: origin not in allowed origins, wrong-app API key, testing-mode cap reached, or trading not available in the user's region. | No. Fix the cause. | | `404` | Not found, or not visible to this user or app. A `404` on `GET /execution/status` is not final. | For that status call, follow the fill-timeout steps below. Do not get a new quote yet. Other `404`s: no. | | `409` | Conflict: a repeated idempotency key, an order that can no longer be cancelled, or a changed withdrawal `requestId` body. | No. Read the existing resource instead. | | `429` | Rate limited. | Yes, after the `Retry-After` header (seconds). | | `503` | A dependency is temporarily unavailable. | Yes, with backoff. | | `5xx` | Server error. | Yes, with backoff, but only for calls that are safe to retry (below). | ## Quote and fill codes These appear on `400` responses from `GET /orderbook/{venueMarketOutcomeId}/route`, `POST /execution/fill`, `POST /execution/orders`, and `POST /execution/limit-orders`. The same condition returns the same status and `code` on all three order routes: for example, a cash balance that does not cover the order is always `400` with `quote_insufficient_balance`, and a venue account or execution mode that changed while the order was being placed is always `400` with `quote_unfillable`. | `code` | Meaning | What to do | | - | - | - | | `quote_not_found` | The quote is not in the cache, usually because it expired. | Follow the fill-timeout steps below. Do not get a new quote until step 4. `fallbackToLatest` is on [Execution](/concepts/execution). | | `quote_expired` | The quote expired, or an earlier fill of this `quoteId` is still running or failed partway. | Check `GET /execution/status?quoteId=` first, then follow the fill-timeout steps. Do not get a new quote until step 4. | | `quote_already_executed` | This `quoteId` was already filled. | Do not re-fill. Poll status for it. | | `quote_cancelled` | The quote was cancelled. | Re-quote. | | `quote_user_mismatch` | The quote belongs to another user, or is a preview quote with no user. | Re-quote with the user's access token. | | `quote_app_blocked` | The route uses a venue or category your app has disabled, or a venue that is no longer available. | Re-quote. The new route avoids it. | | `quote_unfillable` | The quote cannot be executed as priced, or the fill does not match it (for example mode or signing wallet). On `POST /execution/limit-orders`: the funding wallet is not ready yet, or the venue account or execution mode changed while the order was placed. | Re-quote (for a limit order, resend it). Read `message` for the reason. | | `quote_min_order_size` | The amount is below a venue minimum. `message` names it. | Re-quote with a larger amount. | | `quote_insufficient_balance` | The user's balance no longer covers the trade. This includes venue-held cash (Kalshi, a Direct ProphetX or Novig account, TipRun account cash). On `POST /execution/limit-orders`, a sell whose available shares do not cover `sizeRaw` also returns this code. | Refresh balances and re-quote a smaller amount, or fund the account. | | `quote_market_inactive` | The market stopped accepting orders. | Re-quote, or pick another market. | | `quote_self_custody_unsupported_venue` | Self-custody fill routes to a venue without self-custody support. | Re-quote without `signingAddress`, or restrict `allowedVenues`. | | `quote_self_custody_app_fee` | Self-custody fill carries a fee that cannot be collected from this wallet. | See [Self-custody trading](/recipes/self-custody#not-supported-with-the-exact-code). | | `quote_self_custody_redeem_unsupported` | Self-custody sell that settles by redeeming. | Re-quote without `signingAddress`. | | `venue_not_executable` | Direct order on a venue that does not support execution. | Terminal for that venue. Pick another. | | `outcome_ambiguous` | Direct order: several outcomes on the venue match and none is the one you named. `message` lists them. | Send the outcome id that lives on that venue. | | `paper_mode_unsupported` | Direct order from an app in paper trading mode. Direct orders are live only. | Terminal. Paper-trade with `mode: "paper"` on the managed quote and fill. | A rejected fill is remembered for the rest of the quote's life. Sending the same `quoteId` again returns the same error. For `quote_not_found` and `quote_expired`, do not get a new quote until step 4 below. For any other code in the table, get a new quote only when that row says to. ## Idempotency and safe retries | Call | Key | On repeat | After a timeout | | - | - | - | - | | `POST /execution/orders` | `externalId` (required, max 36 chars, unique within your app) | `409`, no second trade. A request rejected before its order was queued does not use up the `externalId`. | Resend with the same `externalId`, or `GET /execution/orders?externalId=` to find the order | | `POST /execution/limit-orders` | `clientOrderId` (optional, unique per user) | `409`, no second order | `GET /execution/orders` and match `clientOrderId` | | `POST /execution/fill` | The `quoteId` | `400` with `quote_already_executed` or `quote_expired`; never a second trade | Resend with the same `quoteId`, or poll `GET /execution/status?quoteId=`. Orders there mean the fill was accepted. `404` is not a final answer; see below. | | `POST /execution/withdraw` | `requestId` (optional UUID, exact-amount only) | Same body returns the original withdrawal; a changed body returns `409` | Retry with the same `requestId` and body | | `POST /execution/fill/{quoteId}/signatures` | The `stepId` | A step that already succeeded is ignored | Resubmit the batch | `POST /execution/fill` has no separate idempotency key: one `quoteId` can create at most one set of orders, so resending the same `quoteId` is always safe. Never get a new quote to retry a fill whose result you do not know, or you can trade twice. After a timeout on `POST /execution/fill`: 1. Keep the same `quoteId`. Poll `GET /execution/status?quoteId=`, or resend the fill with that `quoteId`. 2. Orders in the status response mean the fill was accepted. Track it to a terminal status. 3. A `404` from status means no orders are recorded yet. The original request may still be running, so treat the result as unknown and keep polling. 4. Treat the fill as not executed only when the quote's lifetime has passed, status for that `quoteId` still returns `404`, and a resend with the same `quoteId` returns `quote_not_found` or `quote_expired`. Only then get a new quote. Generate one `externalId` per intended trade, store it before you send the request, and reuse it across retries. A direct order the venue refuses after acceptance still uses up its `externalId`. Reads (`GET`) are always safe to retry. ## Rate limits A `429` carries `Retry-After` in seconds. Wait that long before retrying. Server calls with a validated `x-app-api-key` get their own budget. See [Rate limits](/concepts/rate-limits). # Execution Source: https://docs.agg.market/concepts/execution The fill request body, why the response is always pending, and the checks that can still refuse a quote Every fill is user-tier: send `x-app-id` and the user's `Authorization: Bearer` token. The call returns before the trade finishes. Track it with [Order lifecycle & statuses](/concepts/order-lifecycle). Follow [Quickstart: REST](/quickstart/rest#5-execute-the-quote) for a complete quote-and-fill example. The fields and checks below explain how to handle the execution request. ## Fill body `POST /execution/fill` requires `quoteId` from an executable quote ([Quotes & smart routing](/concepts/quotes)). The response `status` is always `pending`: the trade is accepted, not done. | Field | Required | Use | | - | - | - | | `quoteId` | Yes | The quote to fill. | | `mode` | No | `live` (default) or `paper`. Must match the quote. | | `fallbackToLatest` | No | If `quoteId` has expired, fill the user's latest quote with the same intent. `fallbackToLatest.outcomeId` is an alias of `venueMarketOutcomeId`. Also send `side` and `maxSpend` or `sellShares`, and `allowedVenues` when the quote was scoped. | | `signingAddress` | No | Self-custody wallet that signs the fill. See [Self-custody trading](/recipes/self-custody). | | `fundingAddresses` | No | Other linked wallets this fill may spend. See [Self-custody trading](/recipes/self-custody). | | `approveMode` | No | `approveMode: "sponsored"` or `approveMode: "user_broadcast"`. See [Self-custody trading](/recipes/self-custody). | A `404` from `GET /execution/status` after this call is not final. Retry by following [Errors, retries & idempotency](/concepts/errors). Do not get a new quote until a step there says to. [Execute quote](/api-reference/trading/execute-quote). Direct orders: [Direct orders](/recipes/direct-order-execution). Limit orders: [Limit orders](/recipes/limit-orders). Cancel with `POST /execution/orders/cancel`; a response can stay `cancel_pending`, and a filled or already cancelled order returns `409`. Compare these flows in [Choose your integration](/integration-options). ## Measure order event delivery Use the SDK's optional receipt callback to capture when the client handles an order event. The server timestamp stays in milliseconds. `deliveryDelayMs` is `null` when the clocks disagree or a server timestamp is missing. ```ts theme={null} const socket = client.createWebSocket({ onOrderEventReceipt: (timing) => recordDeliveryTiming(timing), onOrderEvent: (event) => updateOrder(event), }); socket.connect(); ``` Receipt telemetry does not delay execution or suppress order notifications if its callback throws. Compare it with backend bridge timing to separate funding time, confirmation delivery and client delivery. ## Redeem Claiming winnings is [Market resolution & voids](/concepts/market-resolution). EVM redemption completion is the WebSocket event `redeem_event`. [Redeem winnings for resolved positions](/api-reference/trading/redeem-winnings-for-resolved-positions). ## Checks at fill time AGG checks these again when you fill, so a quote that looked fine can still be refused: * The user's location against each venue in the route. A blocked venue returns `403`. * Your app's venue and category settings. Returns `400` `quote_app_blocked`. * The market is still open. Returns `400` `quote_market_inactive`. * The user's balance still covers the trade. Returns `400` `quote_insufficient_balance`. * Testing-mode trade cap. Returns `403`. Codes and retry rules: [Errors, retries & idempotency](/concepts/errors). ## Slippage and price protection `slipCapBps` is how far from a reference price you accept, in basis points. `100` is 1%. On a buy it caps the price per share. On a sell it floors it. `maxSpend` still bounds the total a buy can cost, so the cap changes how many shares that spend buys, not how much is spent. How the cap is used depends on how you place the order. A quote applies it twice. A direct order with `skipQuote` applies it once. ### With a quote This covers [Get a quote](/api-reference/trading/get-a-quote-smart-route) followed by [Execute quote](/api-reference/trading/execute-quote). It also covers [Place an order directly](/api-reference/trading/place-an-order-directly) without `skipQuote`, which builds a quote for you before placing the order. | Step | Reference price | What the cap does | | - | - | - | | Quoting | The market's midpoint | The quote's average price must stay within the cap of the midpoint. A tight cap can make the quote smaller, or leave nothing to quote. | | Execution | The quoted price for each leg | The order cannot fill worse than the quoted price plus the cap. | Your user sees the quoted price before committing. The quote's `legBounds[]` lists the worst price each leg accepts as `worstPrice`, with the cap applied as `effectiveSlippageBps`. ### With a direct order and `skipQuote` [Place an order directly](/api-reference/trading/place-an-order-directly) with `skipQuote` skips the router, so there is no quoting step. The order is sized from one price and sent straight to the venue you name. The cap applies only at execution. | `price` sent | Reference price | | - | - | | Yes | Your `price`. | | No | The best ask (buy) or best bid (sell) AGG reads when it accepts the order. Your user never saw this price. | Here the cap only covers the moment between AGG accepting the order and the venue filling it. Send the price your user saw as `price`, so the cap protects a price they accepted. Use `skipQuote` only when your backend needs the lower latency. Otherwise place the same order without it. ```json theme={null} { "venue": "predict", "venueMarketOutcomeId": "", "side": "buy", "maxSpend": 50, "externalId": "trade-0001", "skipQuote": true, "price": 0.62, "slipCapBps": 300 } ``` The other `skipQuote` rules are on [Direct orders](/recipes/direct-order-execution). ### When the price moves past the cap At execution, the order takes only the liquidity inside the cap. If none is left there when the order reaches the venue, the order fails. The reason says the price moved past the accepted quote and slippage. Read it as `errorReason` on [Get execution status](/api-reference/trading/get-execution-status), or as `errorMessage` on [Get user orders](/api-reference/portfolio/get-user-orders). Nothing fills, and the funds are not spent. A direct order's `externalId` is used up. Do not resend the same request. Read the current price, show it to your user, and on confirmation place a new order with a new `externalId`, or request a new quote. Retrying unchanged either fails again or fills at a price your user did not approve. ### Choosing a cap Prices move in steps, so a cap smaller than one step fails on any movement at all. Read the step from the market's `tickSize` on [List venue markets](/api-reference/markets/list-venue-markets) or [Get live orderbooks](/api-reference/market-data/get-live-orderbooks-for-multiple-markets). Many markets move in one-cent steps, and one cent is a different share of each price: | Price | One cent as basis points | | - | - | | 0.90 | 112 | | 0.62 | 162 | | 0.50 | 200 | | 0.25 | 400 | | 0.10 | 1000 | A 1% cap (`100`) therefore cannot absorb a single one-cent move at any price below 1. Set the cap to at least one step plus a margin for how fast the market moves: ```ts theme={null} // tickSize from the market, price as a 0-1 decimal const oneTickBps = Math.ceil((tickSize / price) * 10_000); const slipCapBps = Math.max(200, oneTickBps + 50); ``` Widen it for fast markets such as live sports, and keep a ceiling your users accept. On cheap outcomes even one step is a large share of the price, so decide whether to trade those at all rather than letting the cap grow without limit. Set the cap on sells too: `sellShares` bounds the shares sold, not the proceeds. ### Venue rules at execution Omitting `slipCapBps`, or sending `10000` or more, uses the venue's default. These rules apply to both quotes and direct orders. | Venue | Default | How the cap applies | | - | - | - | | `kalshi`, `pred` | 0 | Always zero. Orders never execute worse than the reference price. | | `polymarket` | 500 | Rounded inward to the market's `tickSize`, never below the reference price itself. | | `predict` | 500 | Applied as written. | | `myriad` | 200 | Applied as written. | | `hyperliquid` | 50 | Values under `50` are raised to `50`. | | `prophetx` | 500 | The venue prices the order itself within the cap. | # Fees Source: https://docs.agg.market/concepts/fees Every fee on a trade or withdrawal, its unit, when it is charged, and where to read it The quote is the authoritative number for what a trade costs. Read `feeBreakdown` and `totalCostIncFees` before the user commits (see [Quotes & smart routing](/concepts/quotes)). After the trade, `GET /execution/orders` reports `fees.quoted` (the quote-time snapshot) and `fees.actual` for reconciliation. There are no up-front or recurring fees. We only make money when you do, starting at 20% of the fees you charge users, with volume-based discounts available. ## Fee kinds | Fee | Unit | Charged | Quote field | | - | - | - | - | | Venue trading fee | Venue rate per market | When the order fills on the venue | `feeBreakdown.venueFees`, `fills[].venueFee` | | Venue settlement fee | Share of payout or profit | At settlement, on venues that charge this way | Netted from `estimatedPayout`, `fills[].settlementFee` | | Builder fee | USD | With the order, on venues that use builder codes | `feeBreakdown.builderFee` | | App fee (yours) | Basis points of routed notional | Buys, and sells when you set a sell rate. See [App fee](#app-fee) | `feeBreakdown.appFee`, `appFeeBips` | | Referral fee | Basis points of routed notional | Buys, and sells when you set a sell rate. See [Referral fee](#referral-fee) | `feeBreakdown.referralFee`, `referral` | | Bridge fee | USD | When funds move across chains for the trade | `feeBreakdown.bridgeFees` | | Execution gas | USD | Network gas for execution | `feeBreakdown.executionGas` | | Setup costs | USD | One time, per wallet and chain | `feeBreakdown.setupCosts` with `deepEstimate=true` | | Withdrawal fee | Token atomic units | On withdrawal | `feeRaw` on the withdrawal preview | `feeBreakdown.totalCost` is price times shares plus venue, builder, bridge, and gas fees. It excludes app and referral fees. `totalCostIncFees` includes everything. ## Venue fees Each venue sets its own taker fee. `GET /venue-markets` returns it per market as `feeRate`, `feeExponent`, and `feeBasis` (`fill`, `fill_in_game_only`, `winnings`, or `exit`). `null` means AGG does not have the fee, not that it is free. Use these fields to render fee-inclusive price levels, and use the quote for the real cost. Formulas and a per-venue table: [Venue fees](/recipes/venue-fees). ## Builder fee Polymarket and Hyperliquid attribute order flow to a builder. Set your own builder code per venue in the admin dashboard. Without one, the platform default applies. See [Builder codes](/recipes/builder-codes). ## App fee Your app fee is the fee you charge your users. Configure rates per app in the admin dashboard under **Fees**. It applies to buys, in basis points of the routed notional. Sells are free unless you set a **sell rate** (**Fallback sell fee**) under **Fees**; a sell is then charged on gross proceeds (shares × price, before the venue fee) and the fee comes out of the proceeds. The quote shows it in `feeBreakdown.appFee` and nets it out of `estimatedNetProceeds`, which is gross proceeds minus venue fees, builder fees, your app fee and any user-paid referral. Sell fees are collected on Polymarket, Hyperliquid, predict.fun, Limitless, Myriad, Novig and ProphetX. A sell leg on any other venue carries no fee, with a `sell_fee_unsupported_venue` warning on the quote. A sell that fills on more than one venue carries no app fee or user-paid referral, with a `sell_fee_multi_venue` warning. A managed Polymarket sell of shares held outside the user's deposit wallet (a legacy Safe or proxy) carries no app fee or user-paid referral, with a `sell_fee_unsupported_wallet` warning. A managed Novig or ProphetX sell settles on Solana, where a user-paid referral cannot be paid: the app fee still applies, but the referral is priced at 0 (and not deducted from `estimatedNetProceeds`), with a `sell_referral_unsupported_venue` warning. An app-paid referral is unaffected. Warnings use the usual `{ venue, venueMarketOutcomeId, reason }` shape. On a managed buy the quote reserves the app fee out of `maxSpend`. **Partial fill:** the app fee is collected on the amount actually filled. `feeBreakdown.appFee` is the maximum if the buy fills in full. **Where a managed buy's fee comes from.** The fee is collected after the fill, from the user's managed wallet, on the chain of the venue that filled: Polygon for Polymarket, BNB Chain for predict.fun. You don't need to keep funds on that chain. When a buy is funded from another chain, the bridge carries the reserved fee along with the trade, and the fee is collected where it lands: * **From the managed wallet on the venue's chain**, in the stablecoin that is there (for example USDT on BNB Chain). This is used whenever that wallet already holds the whole fee, and it confirms within seconds. * **From the Polymarket deposit wallet**, in pUSD, when the buy's funds went there. This transfer can only be sent once the fill has settled on-chain, so the fee usually confirms within a minute. Until then the charge reads `failed` with a "waits for on-chain settlement" reason, and it is retried automatically. `fees.actual.appFee.status` reads `under_collected` when less than the full fee was collected, including when nothing was (`collectedRaw: "0"`). * A partial collection because the user's wallet was short is final. * On Hyperliquid the partner and platform shares go out as two separate transfers. If one has not gone out yet, a retry sends it and the fee moves to `confirmed`. * A charge that collected nothing is retried automatically, up to 5 times over about 2.5 hours, and is final after that. `fees.actual.appFee.finalizedAt` is the time the fee stopped changing: collected in full, skipped, or no longer retried. It is absent (never `null`) while the fee may still change, so a `$0` `under_collected` without `finalizedAt` is still being retried. **Per-trade override.** A request that carries your `x-app-api-key` can replace the rate for one trade with `appFeeBips` (0 to 10000; `0` waives it) on the quote or a direct order. The quote then reads `feeBreakdown.appFeeCategory: "override"`. Without an API key the request returns `400` with a message starting `app_fee_override_requires_api_key`. The override works on sells too. The platform share applies to an overridden fee the same way. **Venue-specific rates.** To charge a different rate per venue, send `appFeeBipsByVenue` on the quote or a direct order, with the same `x-app-api-key` rule. Keys are venue names and each value is 0 to 10000 bips. A venue's rate is resolved in this order: 1. `appFeeBipsByVenue[venue]` 2. `appFeeBips` 3. your configured rate for the category, then `all` So `appFeeBipsByVenue: { "polymarket": 20, "hyperliquid": 10 }` charges 20 bips on the legs that fill on Polymarket and 10 on the legs that fill on Hyperliquid. A `0` in the map waives the fee on that venue even when `appFeeBips` is set. On a quote: * `feeBreakdown.appFee` charges each venue's share of the notional at its own rate. * `feeBreakdown.appFeeBips` is the highest rate among the venues that route fills. * `feeBreakdown.appFeeBipsByVenue` lists the rate for each venue the route fills. It is present only when those rates differ. * `maxSpend` is reserved at the highest rate among all candidate venues, since the fill venues are not known until the route is built. A venue with a lower rate therefore gets a slightly smaller budget, and `maxSpend` is never exceeded. A direct order fills on one venue, so its rate is the map entry for that venue. On a quote, the map is a JSON string: a malformed map, an unknown venue or a rate that is not a whole number in 0..10000 returns `400` with a message starting `app_fee_override_invalid`. On `POST /execution/orders` the map is an object: an unknown venue returns the same `app_fee_override_invalid`, but a rate that is not a whole number in 0..10000 fails request validation first (`400`, `Request validation failed`). A common split is a fixed total shared with a referrer: `appFeeBips: 20` with no referrer, or `appFeeBips: 10` with `referrerFeeBips: 10` when there is one. The user pays 20 bips either way. Self-custody fills collect the app fee differently. See [Self-custody trading](/recipes/self-custody#fees). ## Referral fee A referral pays a third party out of the same trade. It is a second fee next to the app fee, in basis points of the routed notional, and it goes entirely to `referrer`. AGG takes no share of it. Send these on the quote or on `POST /execution/orders`. They need `x-app-api-key`. Without it the request returns `400` with a message starting `referral_requires_api_key`. The fill uses the quote's referral and cannot change it. | Param | Required | Use | | - | - | - | | `referrer` | | EVM address that receives the fee. Works on sells too, on the same venues as the app fee. | | `referrerFeeBips` | With `referrer` | 1 to 10000. | | `referrerFeeBipsByVenue` | | Per-venue referral rates, 0 to 10000, same precedence as `appFeeBipsByVenue`. `0` means no referral on that venue; at least one venue must end up above 0. A route or order that fills only venues at 0 carries no `referral`. With it, `referrerFeeBips` is optional. | | `referralPayer` | | `"user"` (default) or `"app"`. | | `referralPayerAddress` | With `"app"` | Your wallet that pays the referral. | | `referralPayoutChainId` | | App-paid only. The chain the referrer is paid on. Must carry USDC. | These are HTTP fields. `getSmartRoute` forwards `referrer`, `referrerFeeBips`, `referralPayer`, and `referralPayerAddress`. It does not forward `referralPayoutChainId`. The quote echoes the accepted referral as `referral: { referrer, feeBips, payer, payerAddress?, quotedFeeUsd }`. A user-paid referral also appears in `feeBreakdown.referralFee`. An app-paid referral is not a user cost and never does. ### User-paid **Partial fill:** a user-paid referral is reserved from `maxSpend` and collected the same way as the app fee. ### App-paid The user pays nothing. You pay the referrer from `referralPayerAddress`. **Partial fill:** an app-paid referral pays the quoted amount. A failed fill pays nothing. Without `referralPayoutChainId`, the payout goes on the trade's fee chain, or Polygon when that chain is not EVM. Sending `referralPayoutChainId` with a user-paid referral returns `400` with `referral_invalid`. On HyperCore the referrer receives spot USDC on Hyperliquid. The first payout to a referrer with no Hyperliquid account costs an extra 1 USDC that Hyperliquid charges to create it. Your wallet must hold the payout plus that 1 USDC, or the transfer is refused. Poll and sign that payout on [Referrals](/recipes/referrals). Signature types are on [Self-custody signing](/recipes/self-custody-signing). ### Where a referral cannot be paid A buy that fills only on Kalshi collects its fee from Solana USDC, where an EVM referrer cannot be paid. The quote carries a warning with `reason: "referral_unpaid_on_solana"`, the user is not charged the referral, and nothing is paid. A user-paid referral on a Hyperliquid fill is sent to the referrer on HyperCore. Hyperliquid charges the sender 1 USDC to activate an address that has never received a HyperCore deposit. If the referrer's address is not activated, the referral transfer either costs the user that 1 USDC or, if they can't cover it, is rejected and the referral is not paid. The quote carries a warning with `reason: "referrer_not_activated_on_hypercore"`, and the trade still executes. Fund the referrer's HyperCore address once, with any amount, to clear it. To pay that activation fee yourself instead, send `referralActivationSponsorAddress` on the quote (or the direct order). Before the fill, a pending signature appears beside the order's with `signerAddress` set to that wallet, `purpose: "hl_activation_sponsor"` and `optional: true`: an `eip712` `sendAsset` of 0.000001 USDC to the referrer. Sign it with that wallet and post it on `POST /execution/fill/:quoteId/signatures` together with the order's signature (with the SDK, your `signer` callback receives it). After the fill it is sent before the referral transfer, only if the referrer still has no HyperCore account. The wallet's HyperCore spot balance must hold at least 1.000001 USDC. Declining it is safe: the trade proceeds and the referral is attempted as it would be without it. Self-custody Hyperliquid buys only, user-paid referral only. If you drive signatures with your own client rather than the SDK, post the required and optional signatures together, but never wait more than about 15 seconds on your paying wallet's signature (or any optional request signed by a wallet other than the user's): post without it after that. The order request expires, so the order's signature must not be held for it. An optional request the user's own wallet signs, such as an `hl_referral` prompt, can wait for the user as before. #### Securing the paying wallet's signer > [!WARNING] > > The activation request reaches your backend through the user's browser, so the endpoint that signs it > is a signing service any user can call with whatever typed data they like, for example a > `sendAsset` of the paying wallet's whole balance to themselves, which they could post to > Hyperliquid directly. Sign only when every one of these holds, and otherwise refuse: > > * `primaryType` is `HyperliquidTransaction:SendAsset`. > * `destination` equals the referrer you set on that quote yourself (compare case-insensitively). > * `amount` is exactly `"0.000001"`. > * `token` is exactly `"USDC:0x6d1e7cde53ba9467b783cb7c530ce054"`, Hyperliquid's `SYMBOL:tokenId` wire form for spot USDC. > * `sourceDex` and `destinationDex` are both `"spot"`, and `fromSubAccount` is `""`. > > Better still, rebuild the typed data on your server from the quote you created and sign that, > rather than trusting the client's copy. The paying wallet must be an EOA holding the master key: > Hyperliquid agent (API) wallets cannot sign `sendAsset`, and a smart-contract wallet's signature > fails verification, so the activation payment is skipped. ### Reconciling `GET /execution/orders` reports `fees.quoted.referralFeeRaw` and `fees.actual.referral` per trade: `{ referrer, payer, feeBips, dueRaw, paidRaw, status }`, where `status` is `confirmed`, `failed`, `expired`, or `skipped`. ## Withdrawal fees `POST /execution/withdraw/preview` returns the fee as `feeRaw` and the amount the recipient gets as `receiveAmountRaw`. Fees come out of the withdrawal amount. See [Funding & withdrawals](/concepts/funding). # Funding & withdrawals Source: https://docs.agg.market/concepts/funding Deposit addresses, balances, and withdrawals tracked to a final state, for managed accounts > [!NOTE] > > **You'll need:** a signed-in user ([Authentication & sessions](/concepts/authentication)). > **Result:** the user's deposit addresses, a funded balance, and a withdrawal polled to > `completed`, `partial`, or `failed`. In a managed account, each user gets deposit addresses on the supported chains. The user deposits to any of them, without picking a venue first. AGG tracks the balance per chain and moves funds to the venue a trade routes to. Self-custody users fund from their own wallet instead; see [Self-custody trading](/recipes/self-custody). This includes a balance in the user's Polymarket deposit wallet. A Hyperliquid buy, for example, can be paid from it: AGG moves the funds out of the deposit wallet and bridges them to Hyperliquid, with no signature from the user. The bridge fee applies as on any cross-chain move. All calls below are user-tier: send `x-app-id` and the user's `Authorization: Bearer` token. ## 1. Get deposit addresses `GET /execution/deposit-addresses` returns `202` with `{ "ready": false }` while the addresses are being created. Poll until it returns `200`. ```ts SDK theme={null} let deposit = await client.getDepositAddresses(); while (!deposit.ready) { await new Promise((r) => setTimeout(r, 2000)); deposit = await client.getDepositAddresses(); } ``` ```bash cURL theme={null} curl https://api.agg.market/execution/deposit-addresses \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` [Get deposit addresses](/api-reference/funding/get-deposit-addresses). `202` with `ready: false`, then `200` with `ready: true`. `decimals` below is 6. BNB Chain tokens on this response are 18-decimal; read `decimals`. ```json theme={null} { "ready": true, "evmAddress": "0x8e2d6a1c4f7b0e3d9a5c2f8b1e6d4a7c0f3b9e25", "svmAddress": "5Hq3sT8mZr2vKc9Lw4NpXy7BdJfGa6UeRk1oQi3VtC8n", "supportedChains": [ { "chainId": 137, "name": "Polygon", "tokens": [{ "symbol": "USDC", "address": "0x3c49...3359", "decimals": 6 }] }, { "chainId": 8453, "name": "Base", "tokens": [{ "symbol": "USDC", "address": "0x8335...2913", "decimals": 6 }] } ] } ``` `evmAddress` is the user's deposit address on every supported EVM chain and Hyperliquid: for a Privy wallet-provider app it is the Privy embedded wallet AGG is authorized to sign (409 when the Privy wallet does not grant signing rights), and for other apps it is a managed wallet AGG creates. `svmAddress` receives on Solana. Only send the tokens listed for each chain. Treat `supportedChains` as the source of truth; it can change. Card deposits are [Onramp](/recipes/onramp). The `wallets.ready` webhook fires when a user's addresses are created. ## 2. Deposit and wait for the balance The user sends a supported token to the address. The deposit shows in the balance after on-chain confirmation. There is no confirm call on your side. The `deposits.confirmed` webhook fires for each confirmed deposit. ```ts SDK theme={null} const balances = await client.getManagedBalances(); ``` ```bash cURL theme={null} curl https://api.agg.market/execution/balances \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` [Get balances](/api-reference/portfolio/get-balances). `availableRaw`, `totalRaw`, `reservedRaw`, and `balanceRaw` are integer strings in token decimals (`125000000` with `decimals: 6` is 125 USDC). `positions[].balance` and `costBasis` are display USD, not `availableRaw`. ```json theme={null} { "cash": [ { "tokenSymbol": "USDC", "totalRaw": "125000000", "availableRaw": "125000000", "reservedRaw": "0", "decimals": 6, "chains": [{ "chainId": 137, "balanceRaw": "100000000", "decimals": 6 }] } ], "positions": [{ "venue": "polymarket", "balance": 16.2, "costBasis": 15.3 }] } ``` `availableRaw` is what can be spent now. `reservedRaw` is held for open limit orders and trades in progress. Pass `custody=self` to read the user's linked self-custody wallets instead. To keep a minimum USDC balance on one chain after trades, see [Managed balance refills](/recipes/managed-balance-refills). ## 3. Withdraw Withdrawals always come from the managed balance, to any address on a supported destination chain. Supported tokens are `USDC`, `USDC.e`, and `USDT`. Withdrawal amounts are 6-decimal integer strings, except BNB Chain, which is 18-decimal. Every other raw amount in the samples below is 6-decimal. The minimum is one whole token. Fees come out of the amount. ### Show the maximum For a **Max** button, ask how much can actually arrive after fees: `maxDeliverableRaw` of `124880000` with `decimals: 6` is 124.88 USDC. Not display units. ```ts SDK theme={null} const max = await client.getWithdrawalQuote({ tokenSymbol: "USDC", destinationChainId: 8453, }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/execution/withdrawable/quote \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "tokenSymbol": "USDC", "destinationChainId": 8453 }' ``` [Quote withdrawable amount](/api-reference/funding/quote-withdrawable-amount). When the user picks Max, send `"max": true` on the withdrawal. The server caps the amount to what can be delivered. ### Preview before submitting Optional, but show it before the user confirms: `amountRaw` of `50000000` is 50 USDC at 6 decimals. On BNB Chain the same whole token is an 18-decimal integer string. ```ts SDK theme={null} const preview = await client.withdrawPreview({ amountRaw: "50000000", tokenSymbol: "USDC", destinationAddress: "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52", destinationChainId: 8453, }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/execution/withdraw/preview \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "amountRaw": "50000000", "tokenSymbol": "USDC", "destinationAddress": "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52", "destinationChainId": 8453 }' ``` [Preview a withdrawal](/api-reference/funding/preview-a-withdrawal). The response has `receiveAmountRaw`, `feeRaw`, `pricingStatus` (`quoted` or `unviable`), `unviableReason`, and `quoteExpiresAt`. The preview creates nothing. ### Submit ```ts SDK theme={null} const withdrawal = await client.withdrawManaged({ requestId: crypto.randomUUID(), amountRaw: "50000000", tokenSymbol: "USDC", destinationAddress: "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52", destinationChainId: 8453, }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/execution/withdraw \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "requestId": "8a3f5c2e-1b7d-4e9a-9c6f-2d4b8e1a7c30", "amountRaw": "50000000", "tokenSymbol": "USDC", "destinationAddress": "0x2f9a4c7e1b3d6f8a0c5e9b2d4f7a1c3e6b8d0f52", "destinationChainId": 8453 }' ``` `amountRaw` of `50000000` is 50 USDC at 6 decimals (18-decimal on BNB Chain). The response has a `withdrawalId` and `status: "pending"`. [Withdraw funds](/api-reference/funding/withdraw-funds). `requestId` is an optional UUID that makes an exact-amount withdrawal safe to retry: the same `requestId` and body return the original withdrawal instead of sending twice, and a changed body returns `409`. Do not combine `requestId` with `max`. ### Poll to a final state ```ts SDK theme={null} const terminal = new Set(["completed", "partial", "failed"]); let w = await client.getWithdrawalStatus(withdrawal.withdrawalId); while (!terminal.has(w.status)) { await new Promise((r) => setTimeout(r, 3000)); w = await client.getWithdrawalStatus(withdrawal.withdrawalId); } ``` ```bash cURL theme={null} curl https://api.agg.market/execution/withdrawals/$WITHDRAWAL_ID \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" ``` [Get withdrawal status](/api-reference/funding/get-withdrawal-status). `completedAmountRaw` is an integer string in the destination token's decimals (6, or 18 on BNB Chain). | `status` | Terminal | Meaning | | - | - | - | | `pending` | No | Accepted, not started. | | `bridging` | No | Moving funds across chains. | | `transferring` | No | Same-chain transfer in progress. | | `completed` | Yes | Delivered. `completedAmountRaw` is the amount delivered. | | `partial` | Yes | Some sources delivered. See `completedAmountRaw` and `sources[]`. | | `failed` | Yes | Nothing delivered. See `errorMessage`. | `legs[]` and `sources[]` carry per-transfer status and `txHash` values. ### Amounts and fees `amountRaw` is the amount to deliver, not the amount to debit. How much leaves the balance depends on the route: | Route | Debited | Delivered | | - | - | - | | Same chain, same token | Exactly `amountRaw`. | `amountRaw` minus a small network fee. Never more. | | Bridge or swap | About 2% more than `amountRaw` (`amountRaw / 0.98`). | The debited amount minus the bridge fee. Can be above or below `amountRaw`. | On bridge and swap routes, every bridged or swapped part is always debited a fixed 2% buffer on top, plus a few units of rounding. The buffer does not shrink when the real fee is lower. The whole debited amount goes into the bridge, so whatever the fee does not use reaches the recipient. The user only loses the real fee. The user's balance must cover the debit. A balance of $100 can deliver at most about $98 over a bridge. Asking for more makes the preview `unviable` with `maxDeliverableRaw` set; nothing is debited. `max: true` does this calculation for you. The real bridge fee is roughly a flat dollar amount, set mostly by the destination chain: | Destination | Typical bridge fee | | - | - | | Polygon, Arbitrum, Base, BNB Chain, HyperCore | $0.02–$0.07 | | Solana | about \$0.23 | | Ethereum | $0.15–$0.31 | | Same-chain stablecoin swap | about \$0 | These are measured values, not guarantees. Because the fee is flat, it is a large share of a small withdrawal and a tiny share of a large one: * A 1 USDC bridge to Polygon debits about 1.0204 and delivers about 0.99. The fee was more than the 2% buffer, so the recipient gets less than `amountRaw`. * A 100 USDC bridge to Polygon debits about 102.04 and delivers about 101.99. The fee was less than the buffer, so the recipient gets more than `amountRaw`. `receiveAmountRaw` in the preview is an estimate. The route is priced again when the withdrawal runs, so the delivered amount can differ slightly. There is no mode that debits exactly `amountRaw` and delivers `amountRaw` minus fees. `max: true` withdraws the whole withdrawable balance and delivers that minus fees. On the status response: * `completedAmountRaw` is the amount delivered to the recipient, not the amount debited, so it can be above `amountRaw`. For bridged parts it is the bridge's output at execution time, not an on-chain balance reading. * `sources[].amountRaw` is the amount debited from each source, in that source's `decimals`. Convert each to the same decimals, then add them up for the total debited. ### Minimums Every withdrawal must be at least 1 token. On top of that, each chain's balance only counts toward a withdrawal when it clears a minimum for the kind of transfer it needs: | Transfer from that chain | Minimum | | - | - | | Same chain, same token | $0.05 ($0.10 on BNB Chain, \$0.20 on Ethereum) | | Same chain, other stablecoin | $0.25 ($10 on HyperCore) | | Another chain | $0.50 ($2.50 into Solana, \$3 into Ethereum) | A balance below its minimum is left out of `max` and of `maxDeliverableRaw`. A small leftover on one chain, for example \$0.25, cannot be withdrawn on its own until the user adds more to that chain. ### Unviable reasons When `pricingStatus` is `unviable`, `unviableReason` explains why. Show it to the user, but do not match on the text, since the wording can change. For logic, check `pricingStatus` and, when set, `maxDeliverableRaw`. | `unviableReason` | When | | - | - | | `Minimum withdrawal is 1 {TOKEN}` | The amount is under 1 token. | | `Minimum withdrawal is 1 {TOKEN} after fees` | The amount minus the network fee is under 1 token. | | `Insufficient balance for withdrawal` | Nothing is withdrawable. | | `Requested amount exceeds withdrawable balance. Maximum deliverable is {amount}.` | The amount is above what can be delivered. `maxDeliverableRaw` is set. | | `network fee exceeds the amount for this source` | The network fee would use up a whole source. | | `Withdrawal source amount {amount} on chain {chainId} is too small for Relay fees` | One part is too small to bridge. | | `Not enough bridge liquidity to withdraw {amount} from chain {chainId} right now. Try again shortly.` | The bridge has no capacity right now. | | `No safe route is available to withdraw from chain {chainId} right now ({reason}). Try again shortly.` | Every available route failed a safety check. | | `Withdrawal amount is too small for required fees.` | Fallback when no more specific reason applies. | ## Related > **[Managed balance refills](/recipes/managed-balance-refills)** > > Keep a USDC minimum on a chosen chain. > **[Webhooks](/recipes/webhooks/overview)** > > `wallets.ready` and `deposits.confirmed` events. # Identifiers Source: https://docs.agg.market/concepts/ids Choose the correct identifiers for discovery, trading, charts, and durable cross-venue references. | Job | Id | Not this | | - | - | - | | Join venues for this page | Anchor `id` from unfiltered `GET /venue-events` | `aggKey`, title | | Store a venue event or market in a watchlist | `(venue, externalIdentifier)`, then re-resolve | The anchor id. Clusters merge | | Fetch one contract when the key converges | `aggKey` | Elections, dated markets, null keys | | Trade, chart, orderbook socket | `venueMarketOutcomeId` | `venueMarketId` | | Midpoints and arb | `venueMarketId` | Outcome id | | Durable partner user | HMAC `externalId` | Venue `externalIdentifier` | Filtering `venue` plus `externalIdentifier` returns that member, not the anchor. List responses return one row per matched cluster, and for a matched event `id` is the anchor. Re-resolve it. Do not store it as a permanent key. Hyperliquid HIP-4 books are also split by `deployerVenue`. See [Hyperliquid deployers](/recipes/hyperliquid-deployers). ```ts SDK theme={null} const page = await client.getVenueEvents({ limit: 1 }); const event = page.data[0]; // event.id, event.externalIdentifier, event.aggKey, event.matchedVenueEvents ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" "$API/venue-events?limit=1" \ | jq '.data[0] | {id, externalIdentifier, aggKey, matchedVenueEvents}' ``` Full shape on [List venue events](/api-reference/markets/list-venue-events). Markets for an event are [List venue markets](/api-reference/markets/list-venue-markets). One contract is [Look up venue market outcomes by external identifier](/api-reference/markets/look-up-venue-market-outcomes-by-external-identifier). `aggKey` as a filter is [aggKey](/recipes/agg-key). # Market resolution & voids Source: https://docs.agg.market/concepts/market-resolution How a market settles, how to read a split or refunded outcome, and what happens to positions and open orders When a market ends, AGG records how each outcome settled and updates the user's positions. Most markets settle to one winner. Some settle as a split, where every share pays part of a dollar, or as a void, where holders get their stake back. This page shows how to read each case and what the user has to do. ## Market status `closed` and `resolved` in this section are **market status**, not position status. | Status | Meaning | | - | - | | `closed` | Market status: trading has stopped. On some venues a voided market also ends in `closed`. | | `resolved` | Market status: the venue has published how the market settled. | Status alone does not tell you what an outcome pays. Read the outcome fields below. ## Reading an outcome Read `refundAtCost` first: when it is true the market was voided and a share pays the user's entry price. If `payoutPerShare` is set, that is the USD paid per share, from 0 to 1. Otherwise `winner: true` pays $1 divided by the number of winning outcomes, `winner: false` pays $0, and `winner: null` is not settled yet. Each outcome on `GET /venue-markets`, `GET /venue-events` and `GET /venue-market-outcomes` carries those fields. Check them in this order: | Check | Meaning | What a share pays | | - | - | - | | `refundAtCost: true` | The market was voided and holders are refunded. | The user's entry price | | `payoutPerShare` is set | The venue published a per-share payout. | `payoutPerShare` USD (0 to 1) | | `winner: true` | The outcome won. | \$1 divided by the number of winning outcomes | | `winner: false` | The outcome lost. | \$0 | | `winner: null` | Not settled yet. | Keep showing the market price | A void often shows up as more than one outcome with `winner: true`. Always check `refundAtCost` and `payoutPerShare` before assuming a winner pays \$1. Examples: * **Polymarket 50/50.** Both outcomes have `winner: true` and `payoutPerShare: 0.5`. Each share pays \$0.50. * **Refund at cost.** Every outcome has `refundAtCost: true`. A position bought at 0.42 is refunded at \$0.42 a share. ## Positions after settlement `GET /execution/positions` reflects settlement for each outcome the user holds: * `winner` is set, `currentPrice` becomes the payout per share, and `priceSource` is `settled`. * `redeemStatus` tells you whether to call `POST /execution/redeem`: | `redeemStatus` | Meaning | | - | - | | `eligible` | Claim the payout with `POST /execution/redeem`. | | `pending` | A claim is in progress. | | `redeemed` | Already claimed or settled. | | `ineligible` | Nothing to claim through AGG: the outcome lost, the venue settles on its own, or the position is self-custody and is claimed on the venue. | A position's `status: closed` is **position status**: that holding is settled or sold. It is not market status `closed`. ## Redeem `POST /execution/redeem` claims outcomes whose `redeemStatus` is `eligible`. `mode` (`live` or `paper`) is optional. ```ts SDK theme={null} const claimed = await client.redeem({ venueMarketOutcomeIds: [outcomeId] }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/execution/redeem \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "venueMarketOutcomeIds": ["OUTCOME_ID"] }' ``` [Redeem winnings for resolved positions](/api-reference/trading/redeem-winnings-for-resolved-positions). The call returns before an EVM claim finishes. Read `redeemId` and each `results[]` entry's `status` (`submitted`, `confirmed`, `ineligible`, or `rejected`) and `reason`. That body is not the settlement record above. ## By venue In this table, `closed` and `resolved` are market status. | Venue | How a split or void appears | What the user does | | - | - | - | | Polymarket | `resolved`. A 50/50 has both outcomes `winner: true` with `payoutPerShare: 0.5`. | Managed: call `POST /execution/redeem`, which pays \$0.50 a share. Self-custody: redeem on Polymarket. | | Kalshi | A cancelled or postponed event settles `resolved` at a fair price: YES pays Kalshi's settlement value as `payoutPerShare` and NO pays the rest. Every side that pays anything has `winner: true`. | Nothing. Kalshi settles the payout itself; there is no redeem step. | | Limitless | A 50/50 settles `resolved`. Both outcomes have `winner: true` and their `payoutPerShare`, for example `0.5`. | Managed: call `POST /execution/redeem`, which pays the split. | | Predict | A 50/50, for example a cancelled match, settles `resolved` with both outcomes `winner: true` and no `payoutPerShare`. Each share pays $1 divided by the two winners, $0.50. | Call `POST /execution/redeem`, which pays \$0.50 a share. | | Myriad | A voided market ends as `closed`. Each outcome's `payoutPerShare` is its last market price, which Myriad refunds. | Call `POST /execution/redeem` to claim the refund. `redeemStatus` is `eligible`. | | Hyperliquid | `resolved`. Split or scalar settlements set `payoutPerShare` on each outcome. | Nothing. Positions settle automatically. | | BetDEX | A voided market ends as `closed`. | Nothing. The hosted account settles automatically, and the position closes with the settled result. | | TipRun | A void or fractional settlement ends as `closed`. | Nothing. Positions settle automatically at the venue's payout. | | ProphetX (direct accounts) | A cancelled market, or a game ProphetX settles as a push, a draw or with no official result, is market status `resolved` with `refundAtCost: true` on every outcome. | Nothing. The stake is refunded at the entry price in the user's ProphetX account. | A ProphetX direct account is the user's own ProphetX account, using that account's cash rather than an AGG balance. [Choose your integration](/integration-options) does not list it: AGG does not deposit, withdraw, or redeem those funds, and the position settles in the ProphetX account. ## Open orders When a market closes or resolves, or its end date passes, AGG cancels the user's resting managed limit orders on the venue. They end with status `expired`. Self-custody limit orders on Polymarket and Hyperliquid are different: AGG does not cancel them. They stay on the venue until the venue expires them or the user cancels. See [Limit orders](/recipes/limit-orders). ## Webhooks The webhook `markets.resolved` and the socket event `market_resolved` are different channels, and EVM redeem completion is the socket event `redeem_event`, not `market_resolved`. On either settlement channel, read `refundAtCost` and `payoutPerShare` before you show an amount. When more than one outcome has `winner: true`, the market settled as a split or void. `payoutPerShare` is absent when the venue has not reported it. See the [webhook event reference](/recipes/webhooks/event-reference). ## Related * [Markets, outcomes & matching](/concepts/markets) * [Execution](/concepts/execution): redeeming winnings * [Order lifecycle & statuses](/concepts/order-lifecycle) # Markets, outcomes & matching Source: https://docs.agg.market/concepts/markets Events, markets, and outcomes, and the id you pass to a quote. Cross-venue ids are defined on Ids. ## The data model Every venue lists its own copy of a real-world question. AGG stores each venue's listing and links equivalent ones together into a [matched cluster](/recipes/matched-clusters). | Object | What it is | Id you use | | - | - | - | | Venue event | One venue's event, for example a game or an election | `id` from `GET /venue-events` | | Venue market | One question inside an event, for example "Will X win?" | `id` from `GET /venue-markets` (`venueMarketId`) | | Venue market outcome | One side of a market, usually `Yes` or `No` | `venueMarketOutcomes[].id` (`venueMarketOutcomeId`) | **You trade an outcome.** Quotes, direct orders, and limit orders all take a `venueMarketOutcomeId`. Orderbook and midpoint reads take the market id or the outcome id, depending on the route. The two are not interchangeable. Each object also carries `externalIdentifier`, the venue's own id (a Kalshi ticker, a Polymarket token id). Market `status` is one of `unopened`, `paused`, `open`, `closed`, or `resolved`. Only `open` markets can be traded. How a closed or resolved market pays out, including splits and voids, is covered in [Market resolution & voids](/concepts/market-resolution). ## Find a market to trade ```ts SDK theme={null} import { MarketStatus } from "@agg-build/sdk"; const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5 }); const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id; ``` ```bash cURL theme={null} curl -H "x-app-id: $AGG_APP_ID" \ "https://api.agg.market/venue-markets?status=open&search=bitcoin&limit=5" ``` Each market in `data[]` carries `venueMarketOutcomes[]`. Take the outcome's `id`. Prices are not on the listing; read them from `GET /midpoints` or a quote. For full filters and search modes see [Building market views](/recipes/building-market-views) and the [List venue markets](/api-reference/markets/list-venue-markets) reference. ## 24-hour price change Each outcome carries `priceChange24h`: how far its price moved over the last 24 hours, in absolute probability points. `0.05` means the outcome is up 5 cents; `-0.12` means down 12 cents. It is not a percentage. * `null` means unknown, never zero. Some venues publish no 24-hour change, so we sample their prices ourselves; their markets read `null` for roughly their first 24 hours. * The value is refreshed out of band, so it can trail the live price. Use `GET /midpoints` for the live price itself. How often it refreshes depends on the venue: * Polymarket: about every 5 minutes for the 5,000 most traded markets, and every few minutes for other active markets. Quiet markets can take hours. * Myriad: every few minutes. * Kalshi and Hyperliquid: about every 30 minutes. * Venues we sample ourselves: about every 15 minutes. * `/search` results do not include it; read it from `/venue-markets`, `/venue-events` or `/venue-market-outcomes`. ## Across venues See [Identifiers](/concepts/ids) for cross-venue joins and durable references. To turn a venue's own id into an outcome, call `GET /venue-market-outcomes` ([Look up venue market outcomes by external identifier](/api-reference/markets/look-up-venue-market-outcomes-by-external-identifier)). ## Settlement can differ Venues do not always settle the same question the same way, for example on timing or on a cancelled match. `GET /venue-events/:id` returns a per-venue breakdown of those differences. Show it before users trade across venues. ## Discover markets by topic Use these guides when building a focused discovery experience: * [Live crypto markets](/recipes/live-crypto-markets): browse recurring price windows and add the underlying asset's reference price. * [News feeds](/recipes/news-feed): connect articles to the markets they discuss. * [Correlated markets](/recipes/correlated-markets): find markets connected by related outcome signals, beyond title matching. ## Guides > **[Matched clusters](/recipes/matched-clusters)** > > Why a cluster can look empty. > **[Look up by venue identifier](/recipes/matched-clusters#look-up-by-external-identifier)** > > Resolve a Kalshi ticker or Polymarket token id to the full cluster. > **[aggKey](/recipes/agg-key)** > > When that filter applies. > **[Settlement key differences](/recipes/settlement-key-differences)** > > Show how each venue settles a matched market. # Order lifecycle & statuses Source: https://docs.agg.market/concepts/order-lifecycle Every order status, which ones are final, and how to track a trade to a final state Trading calls return `pending` right away and finish in the background. You find out how a trade ended by polling, or by listening for webhooks and WebSocket events. Always keep going until you reach a terminal state. ## Two levels of status | Level | Where | Values | | - | - | - | | Trade | `overallState` on `GET /execution/status` | One state for the whole quote, across all its orders | | Order | `status` on each order | One status per venue order | A fill split across two venues is one trade with two orders. ## Order statuses | Status | Terminal | Meaning | | - | - | - | | `pending` | No | Accepted. Funds are being checked. | | `signing` | No | The order is being signed. | | `pending_bridge` | No | Funds are moving to the venue's chain. | | `submitting` | No | Being sent to the venue. | | `submitted` | No | Sent to the venue, waiting for confirmation. | | `open` | No | A limit order resting on the venue's book. | | `partially_filled_open` | No | A resting limit order with some fills and size left. | | `cancel_pending` | No | Cancel requested, waiting for the venue to confirm. | | `filled` | **Yes** | Fully filled. | | `partial_fill` | **Yes** | Part filled. The rest will not fill. See `partialFillReason`. | | `failed` | **Yes** | Failed. `GET /execution/status` uses `errorReason`. `GET /execution/orders` uses `errorMessage`. | | `expired` | **Yes** | Timed out, or a limit order reached its expiry. | | `cancelled` | **Yes** | Cancelled by the user or the system. | A market order never shows `open`, `partially_filled_open`, or `cancel_pending`. `partialFillReason` is an open set. Known values: `venue_capacity`, `price_slipped`, `venue_cancelled`, `venue_expired`, `venue_closed`, `user_cancelled`, `timeout`, `clearinghouse_capacity`, `native_remainder_voided`. ```mermaid theme={null} stateDiagram-v2 [*] --> pending pending --> signing pending --> pending_bridge pending --> submitting signing --> submitting pending_bridge --> submitting submitting --> submitted submitted --> filled submitted --> partial_fill submitted --> open: limit order open --> partially_filled_open open --> cancel_pending partially_filled_open --> cancel_pending open --> filled partially_filled_open --> filled partially_filled_open --> partial_fill cancel_pending --> cancelled cancel_pending --> filled pending --> failed submitting --> failed submitted --> failed pending_bridge --> expired open --> expired pending --> cancelled filled --> [*] partial_fill --> [*] failed --> [*] expired --> [*] cancelled --> [*] ``` The diagram shows the usual paths. Not every order visits every state, and a failure can happen from any in-flight state. Branch on the status you read, not on the path you expect. ## Trade states `GET /execution/status` rolls the orders up into `overallState`: | `overallState` | Terminal | Meaning | | - | - | - | | `created` | No | Accepted, execution has not started. | | `routing`, `quoting`, `placing`, `confirming` | No | In progress. | | `filled` | Yes | Every order filled. | | `partially_filled` | Yes | At least one order filled or part filled, and at least one did not fill in full. | | `cancelled` | Yes | Every order was cancelled. | | `expired` | Yes | Every order expired. | | `failed` | Yes | Anything else that ended without a fill. | Stop tracking the trade when `terminal` is `true`. It can stay `false` for a short time after every order is final while remaining execution steps finish. If you requested an app-paid referral, continue tracking that payout separately: its `purpose: "referral_payout"` signing request can appear after the trade is filled. See [Referrals](/recipes/referrals). ## Poll a quote-based trade Use this for `POST /execution/fill`, and for `POST /execution/orders` using the `quoteId` in its response. ```ts SDK theme={null} let status = await client.getExecutionStatus({ quoteId }); // This loop tracks the trade. Track app-paid referrals separately if requested. while (!status.terminal) { await new Promise((r) => setTimeout(r, status.pollAfterMs ?? 1000)); status = await client.getExecutionStatus({ quoteId }); } console.log(status.overallState, status.orders); ``` ```bash cURL theme={null} curl -G https://api.agg.market/execution/status \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "quoteId=$QUOTE_ID" ``` Key fields in the response: | Field | Meaning | | - | - | | `terminal` | `true` once the trade is final. App-paid referral payouts can still need a signature. | | `overallState` | The trade state above. | | `pollAfterMs` | Wait this long before the next poll. `null` when terminal. | | `orders[]` | Per order: `orderId`, `venue`, `status`, `filledAmountRaw`, `actualSharesRaw`, `executionPriceRaw`, `quotedPriceRaw`, `txHash`, `errorReason`. | | `steps[]` | Progress rows for a UI. Group by `groupId` and show `label`. | | `errorReason` | Why the trade failed, on `GET /execution/status`. The order list calls this `errorMessage`. | | `pendingSignatures[]` | Not self-custody only. An app-paid referral puts `purpose: "referral_payout"` here after the trade is filled. See [Referrals](/recipes/referrals). | `filledAmountRaw` and `actualSharesRaw` are 6-decimal integer strings: `1000000` is \$1 or 1 share. `quotedPriceRaw` is a 0–1 decimal string such as `"0.53"`, not a 6-decimal integer. `executionPriceRaw` is the effective filled USD per received/sold share, including fee effects already reflected in those quantities; it is omitted without usable fill quantities and never falls back to the quote. `limitPriceRaw` is the price-scaled integer: `530000` is 0.53. `sizeRaw` (listed as `limitSizeRaw` on `GET /execution/orders`) uses the share scale: `1000000` is 1 share. Do not read `530000` as dollars. `404` means no order is recorded for that `quoteId` for this user yet. Right after a fill request, the request may still be running, so a `404` is not final and should be retried. Do not get a new quote until [Errors, retries & idempotency](/concepts/errors) says to. API reference: [Get execution status](/api-reference/trading/get-execution-status). ## Poll by order id or your own id `GET /execution/orders` lists the user's orders. Filter it to one order: | Filter | Use for | | - | - | | `orderId` | Limit orders, cancels, and any order you hold the id of | | `externalId` | A direct order after a timeout, when you never saw the response | | `quoteId` | Every leg of a quote | | `status`, `orderType`, `venueMarketIds` | Lists and reconciliation | ```ts SDK theme={null} const orders = await client.getExecutionOrders({ orderId }); ``` ```bash cURL theme={null} curl -G https://api.agg.market/execution/orders \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "orderId=$ORDER_ID" ``` [Get user orders](/api-reference/portfolio/get-user-orders). Each row has `status`, `filledAmountRaw`, `actualSharesRaw`, `executionPrice` (not `executionPriceRaw`), `txHash`, and `errorMessage`. `executionPrice` is a 0–1 decimal string such as `"0.53"`, equal to `filledAmountRaw / actualSharesRaw`. It includes a venue fee only where `filledAmountRaw` does: a sell's proceeds are net of fee, while most venues report a buy's fee separately in `actualVenueFeeRaw`. On venues that fold a buy's fee into its cost, a buy near 1 can read slightly above 1. On Hyperliquid it is the quote until the realized fill is recorded, shortly after `filled`. `filledAmountRaw` and `actualSharesRaw` stay 6-decimal integer strings (`1000000` is \$1 or 1 share). On a Polymarket market order, `filledAmountRaw` on a buy is the collateral the order paid, before the venue fee in `actualVenueFeeRaw`. `txHash` is the Polygon transaction that settled the fill. Both are read from the fill's on-chain `OrderFilled` log, usually within a minute of `filled`. Until then, `txHash` holds the Polymarket order hash. A limit order keeps the order hash, since it can settle across several transactions. Poll until `status` is terminal. A resting limit order stays `open` until it fills, is cancelled, or expires. ## Push instead of polling | Channel | Events | Notes | | - | - | - | | [Webhooks](/recipes/webhooks/overview) | `trades.placed`, `trades.filled` | Server-to-server. `trades.filled` carries `status` `filled` or `partial_fill`, and your `externalId` for direct orders. There is no webhook for a failed trade, so poll to catch failures. | | [WebSocket](/recipes/websocket-notifications) | `order_event` | For a signed-in user in the browser. Use REST to backfill on page load and reconnect. | Payloads are small. Read the order from the API for amounts and prices. Retries and idempotency: [Errors, retries & idempotency](/concepts/errors). # Quotes & smart routing Source: https://docs.agg.market/concepts/quotes What a quote contains, when it can be filled, how long it lasts, and where fees and warnings show up A quote prices a trade on one outcome across every venue that lists it, and returns the split that fills best. You then fill the quote by its `quoteId`. See [Execution](/concepts/execution) for the fill. ``` GET /orderbook/{venueMarketOutcomeId}/route ``` API reference: [Get a quote (smart route)](/api-reference/trading/get-a-quote-smart-route). ## Request | Query param | Use | | - | - | | `side` | `buy` (default) or `sell`. | | `maxSpend` | Buy: the most to spend, in USD. Required for a buy. | | `sellShares` | Sell: how many contracts to sell. | | `slipCapBps` | Slippage cap in basis points. | | `allowedVenues` | Only route to these venues. Repeat the param for several. | | `compareVenues` | `true` adds per-venue single-venue quotes in `venueSoloQuotes[]`, each with its own `quoteId`. | | `deepEstimate` | `true` adds one-time setup costs to `feeBreakdown.setupCosts`. | | `mode` | `live` (default) or `paper`. | | `signingAddress`, `fundingAddresses` | Self-custody only. See [Self-custody trading](/recipes/self-custody). | | `appFeeBips`, `appFeeBipsByVenue`, `referrer`, `referrerFeeBips`, `referrerFeeBipsByVenue` | Per-trade fee overrides. Send the `...ByVenue` maps as JSON strings, for example `appFeeBipsByVenue={"polymarket":20,"hyperliquid":10}`. Need `x-app-api-key`. See [Fees](/concepts/fees#app-fee). | ```ts SDK theme={null} const quote = await client.getSmartRoute({ venueMarketOutcomeId: outcomeId, tradeSide: "buy", maxSpend: 25, }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/orderbook/$OUTCOME_ID/route" \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "side=buy" \ --data-urlencode "maxSpend=25" ``` In the SDK the trade direction is `tradeSide`. For `signingAddress` or `fundingAddresses`, use the HTTP query as shown in [Self-custody trading](/recipes/self-custody). * Quote `status` (`ok`, `no_orderbooks`) is not a fill HTTP `code` (`quote_not_found`). * `slipCapBps` caps slippage; `appFeeBips` is your fee rate, not slippage. * `status: "no_orderbooks"` means the quote cannot be filled; a warning `reason` of `no_orderbook` only skips one venue. * A preview has no user JWT; an executable quote has the user's JWT. ## Preview vs executable | Sent | Result | | - | - | | `x-app-id` only | A **preview** quote. Use it to show a price. It cannot be filled, because it is not tied to a user. | | `x-app-id` and the user's `Authorization` | An **executable** quote, sized against that user's balances. Fill it with `POST /execution/fill`. | A sell quote always needs a signed-in user. Filling a quote that belongs to someone else, or a preview quote, returns `400` with `code: "quote_user_mismatch"`. ## Can it be filled? Check `status` before you fill: | `status` | Meaning | | - | - | | `ok` | A route was found. Check warnings and use an authenticated quote before filling. | | `insufficient_balance` | The user does not have enough funds. | | `insufficient_position` | Not enough shares to sell. See `positionAvailability`. | | `insufficient_depth`, `no_orderbooks`, `no_bids_above_min_price` | Not enough liquidity. | | `min_order_size_violated`, `insufficient_input_amount` | The amount is too small for the venue. | | `infeasible`, `checker_rejected`, `solver_error`, `invalid_input`, `engine_unavailable` | No valid route could be built. | When `status` is not `ok`, read `error`. `message` is informational. Only fill quotes with `status: "ok"`. ### Sellable shares Every sell quote, `ok` included, carries `positionAvailability`: the user's shares when the quote was built, on the venues the request allows. | Field | Meaning | | - | - | | `totalSellableShares` | Shares the user can sell in total. Asking for more returns `insufficient_position`. | | `venueMarketOutcomes[].availableShares` | Shares sellable on that leg. A leg is a tradeable position or a resolved, redeemable one. | Use it to cap the size you request, for example behind a "Max" button. It is a snapshot, not a reservation: the fill re-checks the live venue balance, so it can still fail if the position changed after the quote. ## Expiry Every quote carries two timestamps: | Field | Meaning | | - | - | | `refreshAt` | From this time, fetch a new quote before showing a price. The current one still fills. | | `expiresAt` | After this time the quote cannot be filled. | A quote currently lasts about 45 seconds, with `refreshAt` about 15 seconds after it is created. Some routes expire sooner. Always read `expiresAt`; never hard-code the window. A fill after expiry returns `400` with `code: "quote_not_found"` or `"quote_expired"`. Follow [Errors, retries & idempotency](/concepts/errors). `fallbackToLatest` is on [Execution](/concepts/execution). A `quoteId` fills at most once. See [Errors, retries & idempotency](/concepts/errors). ## What the quote contains | Field | Meaning | | - | - | | `quoteId` | Pass to `POST /execution/fill`. | | `fills[]` | One entry per venue leg: `venue`, `venueMarketOutcomeId`, `avgPrice`, `venueQty`, `venueFee`, and the price levels used. | | `totalFilled` | Total shares across legs. | | `feeBreakdown` | Line-item costs. See below. | | `totalCostIncFees` | Total the user pays, including app and referral fees. | | `estimatedPayout` | Payout if the outcome wins, net of settlement fees. | | `estimatedProfit`, `returnPct` | Payout minus cost, in USD and percent. | | `slippage` | Volume-weighted price against the reference midpoint. | | `allocations[]`, `bridgeSteps[]` | Which chain's funds pay for which venue, and any cross-chain moves needed. | | `warnings[]` | Venues left out or flagged, without blocking the quote. | ## Fees in the quote `feeBreakdown` is the number to show before the user commits. | Field | Meaning (USD) | | - | - | | `rawExecCost` | Price times shares, before fees. | | `venueFees` | Venue trading fees. | | `builderFee` | Builder-code fee, when present. | | `bridgeFees` | Expected cross-chain transfer fees. | | `executionGas` | Estimated network gas for execution. | | `totalCost` | The sum of the lines above. Excludes app and referral fees. | | `appFee` | Your app fee on this trade, if it fills in full. A sell is charged on gross proceeds. Present only when above 0. | | `referralFee` | A user-paid referral fee, when present. | | `setupCosts` | One-time setup items, only with `deepEstimate=true`. | Use `totalCostIncFees` for the all-in number. [Fees](/concepts/fees) explains each kind. ## Warnings `warnings[]` lists venues the route skipped or flagged. Each entry has `venue`, `venueMarketOutcomeId`, and `reason`. The quote is still usable. Known reasons include `low_liquidity`, `low_balance`, `stale_orderbook`, `empty_book`, `crossed_book`, `no_orderbook`, `app_fee_unsupported`, `referral_unpaid_on_solana`, and `referrer_not_activated_on_hypercore`. **Geo warnings.** A quote can still include venues unavailable in the user's country. The warning reason reads `Venue not available in your region (XX)` when the country is known. Before offering a fill, re-quote with `allowedVenues` restricted to eligible venues, or show that trading is unavailable. The fill checks location again and can return `403`. See [Venue geo availability](/recipes/venue-geo-availability). Self-custody quotes also carry `custodyWarnings[]`. See [Self-custody trading](/recipes/self-custody). ## Related > **[Execution](/concepts/execution)** > > Fill a quote, or place direct and limit orders. > **[Comparing venue prices](/recipes/comparing-venue-prices)** > > Show per-venue prices next to the routed price. # Rate limits Source: https://docs.agg.market/concepts/rate-limits Per-key and per-endpoint request limits, and how to handle a 429 > [!NOTE] > > Need higher limits? Email [support@agg.market](mailto:support@agg.market) with your app ID, the > API key name (never the secret), the endpoints you call, and your expected peak requests per > minute. We can raise limits per key and per endpoint group. ## Overall limits | Caller | Limit | | - | -: | | Your backend, with a valid `x-app-api-key` | 18,000 requests per minute per key | | Browsers and other clients without a key | 1,000 requests per minute per IP | Each API key has its own budget, separate from your other keys and your browser traffic. Browser traffic is also limited per user, so one busy user cannot use up the limit for the rest of your app. See [Server API keys](/environments#server-api-keys). ## Endpoint limits Some endpoints have their own per-minute limits on top of the overall limit. A higher key limit does not raise these. | Endpoint group | Endpoints | Per IP | Per user | Per API key | | - | - | -: | -: | -: | | Event bootstrap | `GET /venue-events/{id}/bootstrap` | 120 | 240 | 1,200 | | Chart manifest | `GET /charts/history/manifest` | 240 | 480 | 2,400 | | Chart bars | `GET /charts/bars` | 120 | 240 | 1,200 | | Search | `GET /search` | 120 | 240 | 1,200 | | Discovery | `GET /venue-events`, `GET /venue-events/{id}`, `GET /venue-markets`, `GET /venue-market-outcomes`, `GET /crypto/recurring-markets`, `GET /sports/live`, `/correlated-markets/*`, `GET /news-feed/*`, `GET /leaderboard/*` | 600 | 300 | 3,000 | | Prices | `GET /midpoints`, `GET /orderbooks` | 600 | 600 | 1,200 | | Outcome order books | `GET /orderbooks/outcomes`, `GET /orderbook/outcome/{outcomeId}` | 600 | 1,200 | 6,000 | | Quotes | `GET /orderbook/{outcomeId}/route`, `POST /execution/withdraw/preview`, `POST /execution/withdrawable/quote` | 600 | 600 | 1,500 | | Portfolio | `GET /execution/status`, `GET /execution/positions`, `GET /execution/orders`, `GET /execution/balances`, `GET /execution/deposit-addresses`, `GET /execution/withdrawals/{id}`, `GET /users/activity` | 600 | 600 | 3,000 | | Trading | `POST /execution/fill`, `POST /execution/orders`, `POST /execution/limit-orders`, order cancels, `POST /execution/redeem`, `POST /execution/withdraw`, `POST /execution/sync-balances` | 120 | 120 | 6,000 | All endpoints in a group share one budget. For example, a fill and a cancel both count toward Trading. Order-book reads count one unit per outcome. A `GET /orderbooks/outcomes` call for 10 outcomes uses 10. Sign-in and email endpoints have tighter limits to stop abuse. A user can receive up to 10 magic-link emails per hour. ## Handling a 429 A `429` includes a `Retry-After` header in seconds. Wait at least that long before you retry. Do not cache a `429`, and do not treat it as empty data. A `403` from a high-volume source can mean our network firewall blocked it. Back off for a few minutes. If one server IP will send a lot of traffic, tell us before you launch. To stay under the limits: * Use the [orderbook socket](/recipes/websocket-orderbook) instead of polling order books. * Debounce typeahead before calling `GET /search?mode=instant`. * Route reads that fan out for many users through your backend, with an API key. Before retrying a write, read [Errors, retries & idempotency](/concepts/errors). # Base URL, app IDs & API keys Source: https://docs.agg.market/environments The API base URL, where your app ID and server API key come from, and which headers go on each call ## Base URL | | URL | | - | - | | REST API | `https://api.agg.market` | | WebSocket | `wss://ws.agg.market/ws?appId=YOUR_APP_ID` | | Admin dashboard | `https://admin.agg.market` | There is one environment. Orders route to the real venues, so a live fill spends real funds. Use [paper trading](/recipes/paper-trading) to test without them. ## Your app ID Every request that uses an app id carries it in the `x-app-id` header. Find it in the admin dashboard: pick your app, then open **API** under **Develop**. The same page lists your API keys. ## Allowed origins Your app's allowed origins are the sites your browser code runs on. Manage them in the admin dashboard under **Domains**. Enter bare origins such as `https://app.example.com`, with no path, query, or wildcard. They are used in three places: * A request that sends an `Origin` header must match one of them, or it gets `403 Origin not allowed for this app`. Server requests usually send no `Origin` and skip this check. * A browser WebSocket connection must send a matching `Origin`. Servers connect with `x-app-api-key` on the handshake instead. See [Server connections](/api/websocket#server-connections). * A SIWE or SIWS sign-in message names a domain. It must match an allowed origin, or sign-in fails with `401` and `code: "unregistered_domain"`. * OAuth and magic-link `redirectUrl` values must be on an allowed origin. An app with no allowed origins cannot complete wallet sign-in. ## Headers The public API uses the credentials below. Your app ID identifies the integration and is safe to include in browser or mobile code. A user access token authorizes actions for that user. Server API keys are secrets and must stay on your backend. | Set | Headers | Calls | | - | - | - | | 1 | `x-app-id` | Markets, market data, news, correlated markets, and most of auth. Also the smart-route quote `GET /orderbook/{venueMarketOutcomeId}/route`, geo policy `GET /app/venue-geo-policy`, the leaderboard `GET /leaderboard`, and both ramp calls `POST /ramp/quotes` and `POST /ramp/session`. | | 2 | `x-app-id` and `Authorization: Bearer ` | Users, hosted venue accounts, funding except the two ramp calls, trading except the quote, and portfolio except the leaderboard. Sign-out (`POST /auth/sign-out`) is the auth call in this set. | | 3 | `x-app-api-key`; `x-app-id` is not required. | Partner-admin reads, paper-trading admin calls, and webhook rotate and replay (`POST /apps/{appId}/webhooks/{webhookId}/rotate-secret` and `POST /apps/{appId}/webhooks/{webhookId}/replay`). | Exception: `POST /bot-protection/verify` requires both `x-app-id` and `x-app-api-key`. See [Bot protection](/recipes/bot-protection). A user JWT on the smart-route quote turns a preview into an executable quote. A sell still requires a signed-in user. `referrer` is rejected without `x-app-api-key`. `skipQuote` and `skipBalance` are rejected without an `x-app-api-key` that can write (`read_write`); a read-only key is refused. That key does not replace `x-app-id`. Users sign in on the client. Your backend sends that user's access token on set 2 calls. See [Authentication & sessions](/concepts/authentication). ## App config `GET /app/config` is UI config for the app: disabled venues, disabled categories, early access, auth providers, trading mode, and the paper starting balance. Send `x-app-id` only. A user session and an API key are not required, including when the app requires a key on other routes. An unknown or inactive app returns 404. The response has no secrets. A successful response is cached at the edge until the app changes. ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); const config = await client.getAppConfig(); ``` ```bash cURL theme={null} curl https://api.agg.market/app/config \ -H "x-app-id: your-app-id" ``` Full shape on [Get the per-app UI config (disabled venues + categories)](/api-reference/markets/get-the-per-app-ui-config-disabled-venues-+-categories). ## Server API keys Send `x-app-api-key` from your backend on API-key routes, on `POST /bot-protection/verify`, and when using per-trade fee overrides, referrals, `skipQuote`, or `skipBalance`. You can also send a key with server-side market-data requests to use its rate-limit budget. Keep `x-app-id` on app and user routes, and keep the user token where required; the key does not replace them. > [!WARNING] > > An API key is a secret. Never put it in browser bundles, source maps, public repositories, or > logs. ### Create a key In the admin dashboard, pick your app, open **API**, and create a key. The key is shown once. Store it in your secret manager right away. AGG keeps only a hash and cannot show it again. Keys look like `agg__<64 hex characters>`. Keep that prefix unchanged when reusing a key. A key that is missing, malformed, unknown, revoked, or expired is rejected with `401`. On app and user routes, a `read` or `read_write` key can be sent with any active `x-app-id`; app membership is not required for reuse. The target app's settings and allowed origins apply, and a user token must be issued for that target app. The key retains its scope and rate-limit budget. Keep shared keys secure and revoke them if compromised. Admin routes continue to require a key issued for their target app. A `trade` key is the exception: it works only with the app id in the key. Use a `read` key for reporting and a `read_write` key for operations that change state. `skipQuote` and `skipBalance` require `read_write`. A `trade` key lets a market maker sign in with their own wallets and trade on this app. It does not create users, it cannot read other users, and it cannot set app fees or referrals. A wallet is the same user on your app whichever of these keys it used to sign in, and a token from one key works when the caller sends the other. Send the trader key on every call. A user token without the key uses the lower per-user limits and counts toward that user's budget. Calls that include the trader key use the key's own copy of the app's server limits and do not draw down the app's user budgets. See [Market maker trading](/recipes/market-maker). Manage app members and settings in the admin dashboard. For app-wide reporting, follow [Partner admin](/recipes/partner-admin) to read users, balances, orders, and analytics from your backend with this key. ### Rotate a key 1. Create a new key. 2. Deploy your backend with it. 3. Check traffic moved to it (`lastUsedAt` in the dashboard). 4. Revoke the old key. Revoked and expired keys fail at once with `401`. ## Rate limits A validated key gets its own per-key budget, by default 18,000 requests per minute, and skips per-IP limits. Endpoint limits still apply. See [Rate limits](/concepts/rate-limits), which also explains how to request a higher limit. ## Testing mode New apps start in testing mode, with two caps: | Limit | Cap | Counts | | - | - | - | | Users | 20 | Unique users who sign in to your app | | Trades | 100 | Orders placed, not counting failed orders | Existing users can always sign in. Past the user cap, `POST /auth/verify` returns `403` for new users. Past the trade cap, trading calls return `403`. Both carry a readable `message`: ```json theme={null} { "statusCode": 403, "message": "This app has reached its testing limit of 100 trades. Sign the partner agreement to enable live mode and continue placing trades." } ``` OAuth and magic-link sign-in report the user cap on the redirect URL as a `message` query parameter with `error=testing_user_limit_reached`. To lift the caps, sign the partner agreement in the admin dashboard (**Go Live**). It takes effect at once and cannot be undone from the dashboard. Existing users and trades are kept. # Choose your integration Source: https://docs.agg.market/integration-options Managed quote and fill, direct orders, limit orders, self-custody, and hosted venue accounts compared Every option trades the same markets. They differ in who holds the funds, who signs, and what you build. ## At a glance | Option | Funds held in | Who signs | You implement | Pick it when | Guide | | - | - | - | - | - | - | | **Managed quote + fill** | The user's managed balance | AGG | Quote, show price, fill, poll status | You want the best price across venues and a simple flow. The default. | [Quickstart: REST](/quickstart/rest) | | **Direct order** | The user's managed balance | AGG | One call with your `externalId`, then poll | Your backend already knows the venue and amount, and you want idempotent server calls. | [Direct order execution](/recipes/direct-order-execution) | | **Limit order** | The user's managed balance (reserved while open) | AGG | Place, poll until open, cancel | You want price control on one venue and can wait for a fill. | [Limit orders](/recipes/limit-orders) | | **Self-custody** | The user's own EVM wallet | The user's wallet | Quote and fill with `signingAddress`, then a loop that signs each request | Users must keep their own keys. Needs a client that can sign. | [Self-custody trading](/recipes/self-custody) | | **Hosted venue account** | An account AGG hosts for the user on one venue (BetDEX) | AGG | Provision the account, then deposit and withdraw per venue | You need that venue's native account. | [Hosted venue accounts](/recipes/hosted-venue-accounts) | Only managed quote and fill accept `mode=paper` on the order itself. Direct orders reject it. ## Other surfaces These are not rows in the table above. | Surface | What it is | Guide | | - | - | - | | **Privy** | AGG signs the user's Privy wallets. The user is not present to sign. | [Privy wallet provider](/recipes/privy-wallet-provider) | | **Paper** | `mode=paper` on the quote and the fill, plus a separate admin API. | [Paper trading](/recipes/paper-trading) | | **ProphetX direct accounts** | The user's own ProphetX account. A cancelled market refunds there. | [Market resolution](/concepts/market-resolution) | | **Onramp** | A fiat purchase into a wallet address. Not a deposit address. | [Onramp](/recipes/onramp) | | **Refills** | Keep a managed USDC balance above a minimum on one chain after a trade. | [Managed balance refills](/recipes/managed-balance-refills) | | **KYC** | Shared KYC for ProphetX and the same check on other venues that ask for it. | [KYC](/recipes/kyc) | | **Venue keys** | The user's own Kalshi API key. Every Kalshi trade uses it. See [Kalshi](/venues/kalshi). | [Venue keys](/recipes/venue-keys) | ## Managed quote + fill `GET /orderbook/{venueMarketOutcomeId}/route` with the user's token returns a `quoteId` that may split across venues. `POST /execution/fill` executes it. You show the quote's price and fees before the user confirms. Quotes last about 45 seconds. Start with [Quickstart: REST](/quickstart/rest). Concepts: [Quotes & smart routing](/concepts/quotes), [Execution](/concepts/execution). ## Direct order `POST /execution/orders` places a market order on one venue you name. There is no quote step, and it never splits. `externalId` is required, so a timed-out request can be retried without trading twice. `maxSpend` caps the all-in cost. Live only. Guide: [Direct order execution](/recipes/direct-order-execution). ## Limit order `POST /execution/limit-orders` rests an order at your price on one venue, with a time in force (`GTC`, `GTD`, `FOK`, `FAK`, `IOC`, `ALO`; venues support subsets). Funds or shares are reserved while it is open. Track it with `GET /execution/orders?orderId=`; `open` is not final. Guide: [Limit orders](/recipes/limit-orders). ## Self-custody The user links an EVM wallet to their account. Quotes and fills name it as `signingAddress`. AGG prices against that wallet's balances and then asks the wallet to sign each step: venue orders, funding moves, and approvals. AGG never holds the key. The user must be present to sign, and each request expires. Supported on Polymarket, Hyperliquid, and Kalshi (with the user's own Kalshi API key; see [Venue keys](/recipes/venue-keys)). Other venues are refused at fill time. Guides: [Self-custody trading](/recipes/self-custody) and [Self-custody signing reference](/recipes/self-custody-signing). ## Hosted venue account For BetDEX, AGG can host a venue account for the user. You provision it, read its balance, get a Solana USDC deposit address for it, and withdraw from it. See [Hosted venue accounts](/recipes/hosted-venue-accounts). The provision call is [Provision hosted venue account](/api-reference/hosted-venue-accounts/provision-hosted-venue-account). ## Where your code runs | Setup | How it works | | - | - | | Browser only | Your app signs users in and calls AGG with `x-app-id` and the user's token. Add [allowed origins](/environments#allowed-origins). | | Browser plus backend | Users sign in on the client. Your backend calls trading routes with `x-app-id` and that user's access token. Send `x-app-api-key` only for the header set in [Base URL, app IDs & API keys](/environments). It does not replace `x-app-id`. | | UI kit | Drop in `@agg-build/ui` components; they call the same API. See [Quickstart: React](/quickstart/react). | Server-only features, such as per-trade `appFeeBips`, referral fees, and `skipQuote` on direct orders, need `x-app-api-key`. # Overview Source: https://docs.agg.market/introduction How a trade works in AGG, from sign-in to withdrawal, and where to start AGG is a white-label prediction market aggregator. Your users trade markets listed on several venues, such as Polymarket, Hyperliquid, and Limitless, through one API. AGG matches the same market across venues, quotes the best split, executes it, and tracks every order. See [Fees](/concepts/fees). ## What are you building? | You are building | Who signs in | Credentials | Start here | | - | - | - | - | | A trading bot or AI agent that trades its own funds | The bot, with its own wallet key | `x-app-id` and the bot's access token | [Quickstart: trading bot / agent](/quickstart/agent) | | A platform where many users trade | Each user, on your client | `x-app-id` and each user's access token. Your backend adds `x-app-api-key` for server features. | [Quickstart: REST](/quickstart/rest), then [Choose your integration](/integration-options) | | A trading terminal or other UI | Each user, in the browser | `x-app-id` and the user's token, managed by the SDK. Never ship `x-app-api-key` to a browser. | [Quickstart: React](/quickstart/react) | The API reference has two tabs. **Trading API** is every call made with `x-app-id`, plus a user's access token where one is needed: markets, quotes, orders, funding, and the user's account. **Server API** is the calls your backend makes with `x-app-api-key`: partner admin reads, webhooks, and paper accounts. ## The trade lifecycle Every integration follows the same six steps. Each links to the page that explains it. | Step | What happens | Main calls | | - | - | - | | 1. [Authenticate](/concepts/authentication) | The user signs in with a wallet, OAuth, email, or Privy. You get an access token. | `POST /auth/start`, `POST /auth/verify` | | 2. [Find a market](/concepts/markets) | Browse or search events and markets. You trade an **outcome**, identified by `venueMarketOutcomeId`. See [Ids](/concepts/ids). | `GET /venue-events`, `GET /venue-markets` | | 3. [Fund](/concepts/funding) | The user deposits USDC to their deposit address on a supported chain. | `GET /execution/deposit-addresses`, `GET /execution/balances` | | 4. [Quote](/concepts/quotes) | AGG prices the trade across every venue that lists the outcome and returns a `quoteId`. | `GET /orderbook/{venueMarketOutcomeId}/route` | | 5. [Execute & track](/concepts/execution) | You fill the quote, then poll until the trade reaches a [terminal state](/concepts/order-lifecycle). | `POST /execution/fill`, `GET /execution/status` | | 6. [Settle & withdraw](/concepts/funding#3-withdraw) | Winning positions are redeemed after resolution. The user withdraws to any address. | `POST /execution/redeem`, `POST /execution/withdraw` | ## Execution and custody models | Model | Who holds the funds | Who signs | Use it for | | - | - | - | - | | Managed | A managed balance AGG holds for the user | AGG | The default. Simplest flow, best-price routing across venues. | | Self-custody | The user's own EVM wallet | The user's wallet, one request at a time | Users who must keep their own keys. | | Hosted venue account | An account AGG hosts for the user on one venue | AGG | Venues where a native account is needed. | Every wallet and venue account, and which address to pass where, is on [Accounts & wallets](/concepts/accounts-and-wallets). Every venue is on [Venues](/venues/overview). [Choose your integration](/integration-options) is five styles inside these three custody models. Privy, paper admin, and ProphetX direct accounts are not in that table. Within the managed model you can fill a quote, place a direct order on one venue, or rest a limit order. ## How you integrate AGG ships as a REST API and four npm packages. Pick the layer that fits: * **REST API**: any language. Start with [Quickstart: REST](/quickstart/rest). * **`@agg-build/sdk`**: a TypeScript client for browsers, Node.js, and React Native. Find methods and their API endpoints in the [SDK reference](/sdk/reference). * **`@agg-build/hooks`**: React hooks with caching and WebSocket subscriptions. * **`@agg-build/ui`** and **`@agg-build/auth`**: drop-in trading and sign-in components. Start with [Quickstart: React](/quickstart/react). Each package includes the layers below it. See the [Packages overview](/packages/overview). ## Start here > **[Quickstart: trading bot / agent](/quickstart/agent)** > > One headless process: sign in with a key, quote, fill, track. > **[Quickstart: REST](/quickstart/rest)** > > Zero to a filled order over REST. > **[Quickstart: React](/quickstart/react)** > > Provider, sign-in, and a first trade in a React app. > **[Choose your integration](/integration-options)** > > Managed, direct, limit, self-custody, or hosted accounts. > **[Base URL & API keys](/environments)** > > Base URLs, app IDs, API keys, and headers. # Packages Source: https://docs.agg.market/packages/overview Four npm packages — pick the layer that fits your app # Packages AGG ships four packages under `@agg-build`. Each builds on the one below it: ``` @agg-build/auth → Auth UI + provider adapters @agg-build/ui → Pre-built React components (Liveline charts, orderbooks, pages) @agg-build/hooks → React hooks + providers (data fetching, WS, caching) @agg-build/sdk → Vanilla TypeScript client (REST + WebSocket + CandleBuilder) ``` Pick the highest layer you need. Lower layers are included as dependencies automatically. | Package | Use when | Framework | | - | - | - | | `@agg-build/sdk` | Full control, or not using React (Node, React Native, vanilla JS) | None | | `@agg-build/hooks` | React app, you build your own UI, you pick your own chart library | React | | `@agg-build/ui` | You want drop-in components that work out of the box | React | | `@agg-build/auth` | You want the connect/sign-in UI with pluggable auth methods | React | *** ## `@agg-build/sdk` Framework-agnostic client for browsers, Node.js, and React Native. Zero peer dependencies and, as of v4, zero runtime dependencies. **REST client** — `createAggClient()` handles auth headers, in-memory session state, and request helpers for the public REST API. Use `refreshAccessToken()` when you need to renew a user session. Tokens are never persisted to storage — see [Token storage](/recipes/authentication#token-storage) for what that means in React Native. **WebSocket client** — `AggWebSocket` manages connection lifecycle, subscriptions, and live orderbook state for you. **CandleBuilder** — Builds OHLCV candles from live orderbook and trade data so you can feed any chart library. **Market aggregation** — `mergeCandles()` is a client-side volume-weighted merge of candle objects the caller already has. It is not a chart API parameter, and the API does not merge candles. `mergeClosedCandles()` is the same helper for arrays of closed candles the caller already has. [View on npm →](https://www.npmjs.com/package/@agg-build/sdk) *** ## `@agg-build/hooks` React hooks and providers on top of `@agg-build/sdk`. Powered by TanStack React Query for caching, deduplication, and background refetch. The hooks are **chart-library-agnostic** — they return generic data types (`LiveCandle[]`, `OrderbookState`, etc.) that you render however you want. [View on npm →](https://www.npmjs.com/package/@agg-build/hooks) *** ## `@agg-build/ui` Pre-built React components for charts, orderbooks, trading, and full page layouts. Styled with Tailwind CSS and fully themeable via CSS variables. Components must be rendered inside ``. They call hooks internally — no manual data fetching needed. Uses grouped entry points (`@agg-build/ui/pages`, `@agg-build/ui/events`, `@agg-build/ui/primitives`, etc.) for tree-shaking. Browse the [components docs](/components/overview) for curated references, or open [Storybook](https://storybook.agg.market) for the live component catalog and interactive states. Use [Customize UI](/components/customization) for brand tokens, custom fonts, labels, formatting, and layout slots. [View on npm →](https://www.npmjs.com/package/@agg-build/ui) *** ## `@agg-build/auth` Optional auth package with a modular adapter pattern and the `ConnectButton` chooser UI. Auth stays separate so partners only install the wallet or OAuth dependencies they actually use, while `@agg-build/ui` remains focused on market data and trading surfaces. See the [Connect Button reference](/components/auth/connect-button) for the live auth surface. ### Built-in adapters | Adapter | Import | Peer dependency | | - | - | - | | SIWE (Ethereum) | `@agg-build/auth/siwe` | `wagmi` | | SIWS (Solana) | `@agg-build/auth/siws` | `@solana/wallet-adapter-react`, `bs58` | | Google OAuth | `@agg-build/auth` | None | | Twitter/X OAuth | `@agg-build/auth` | None | | Apple Sign In | `@agg-build/auth` | None | | Email magic link | `@agg-build/auth` | None | [View on npm →](https://www.npmjs.com/package/@agg-build/auth) *** ## Versioning All packages use linked versioning. When one changes, the rest are versioned in lockstep so compatible layers stay aligned. See the [Changelog](/changelog) for the full release history and per-version notes across all four packages. ## Next steps > **[Setup Guide](/quickstart/react)** > > Provider configuration and code examples for each integration path. > **[Authentication](/recipes/authentication)** > > Wallet, OAuth, and email sign-in flows on top of the package stack. > **[Real-Time Charts](/recipes/websocket-charts)** > > Use `CandleBuilder`, hooks, or UI components for live charting. > **[Component References](/components/overview)** > > Browse the higher-touch AGG UI surfaces and jump to Storybook. > **[Customize UI](/components/customization)** > > Theme AGG components for your brand without forking UI internals. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Build live orderbook views with venue attribution. > **[Funding & Withdrawals](/concepts/funding)** > > Execution balances, deposit addresses, sync, and withdrawal workflows. > **[Changelog](/changelog)** > > Release history and per-version notes across all four packages. # Quickstart: trading bot / agent Source: https://docs.agg.market/quickstart/agent One headless TypeScript process that signs in with its own key, quotes, fills, and tracks a trade ## Decide in 30 seconds | You are building | Who signs in | Headers on trading calls | Start here | | - | - | - | - | | A bot or agent trading its own funds | The bot, once, with its own wallet key (SIWE) | `x-app-id` and `Authorization: Bearer ` | This page | | A platform for many users | Each user, on your client | `x-app-id` and that user's `Authorization: Bearer` token. Your backend adds `x-app-api-key` only for server features. | [Quickstart: REST](/quickstart/rest), [Authentication & sessions](/concepts/authentication) | | A trading terminal UI | Each user, in the browser | `x-app-id` and the user's token, sent by the SDK. Never ship `x-app-api-key` to a browser. | [Quickstart: React](/quickstart/react) | Which call needs which header: [Base URL, app IDs & API keys](/environments#headers). A bot is one user. It signs a SIWE message with its own private key, gets a user access token, and trades from that user's managed balance. No browser, no API key. > [!NOTE] > > **You'll need:** > > * An app ID from the admin dashboard (`https://admin.agg.market`), under **API**. > * One allowed origin on that app (**Domains**), for example `https://bot.example.com`. It never > has to serve a page. Wallet sign-in names it, and an app with none cannot complete sign-in. > * Node 18 or later, `@agg-build/sdk`, and `viem`. > * A private key for the bot. Keep it out of source control. > > **Result:** a filled order, polled until `terminal` is `true`, then the bot's positions. The steps below run top to bottom as one file. The same code as a single script is in [Full script](#full-script). It trades in [paper mode](#paper-mode) unless you set `AGG_LIVE=1`. ## 0. Set up ```ts SDK theme={null} // Add these imports (the full script below has them): // import { createAggClient, isAggApiError, MarketStatus } from "@agg-build/sdk"; // import { privateKeyToAccount } from "viem/accounts"; const ORIGIN = "https://bot.example.com"; // one of your app's allowed origins const MODE = process.env.AGG_LIVE === "1" ? "live" : "paper"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: process.env.AGG_APP_ID ?? "your-app-id", persistSession: false, // no browser storage }); const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as `0x${string}`); ``` ```bash cURL theme={null} npm install @agg-build/sdk viem export API=https://api.agg.market export APP_ID=your-app-id export ORIGIN=https://bot.example.com export DOMAIN=${ORIGIN#*://} export PK=0x... # the bot's private key export ADDRESS=$(cast wallet address --private-key $PK) # Foundry's cast ``` ## 1. Sign in with the bot's key Get a nonce, build the SIWE message, sign it locally, and verify it. `verify()` keeps the access and refresh tokens in the client and sends them from then on. ```ts SDK theme={null} async function signIn() { const start = await client.authStart({ provider: "siwe" }); if (start.type !== "nonce") throw new Error(`Expected a nonce, got ${start.type}`); const message = client.buildSiweMessage({ address: account.address, chainId: 1, nonce: start.nonce, statement: start.statement, // set only when your app configures one domain: new URL(ORIGIN).host, uri: ORIGIN, }); const signature = await account.signMessage({ message }); return client.verify({ message, signature }); } const { user } = await signIn(); console.log("signed in as", user.id); ``` ```bash cURL theme={null} NONCE=$(curl -s -X POST $API/auth/start -H "x-app-id: $APP_ID" \ -H "content-type: application/json" -d '{ "provider": "siwe" }' | jq -r .nonce) MESSAGE=$(printf '%s wants you to sign in with your Ethereum account:\n%s\n\n\nURI: %s\nVersion: 1\nChain ID: 1\nNonce: %s\nIssued At: %s' \ "$DOMAIN" "$ADDRESS" "$ORIGIN" "$NONCE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)") SIGNATURE=$(cast wallet sign --private-key $PK "$MESSAGE") AUTH=$(curl -s -X POST $API/auth/verify -H "x-app-id: $APP_ID" -H "content-type: application/json" \ -d "$(jq -n --arg m "$MESSAGE" --arg s "$SIGNATURE" '{message: $m, signature: $s}')") export ACCESS_TOKEN=$(echo "$AUTH" | jq -r .accessToken) export REFRESH_TOKEN=$(echo "$AUTH" | jq -r .refreshToken) ``` [Start authentication](/api-reference/authentication/start-authentication) · [Verify sign-in](/api-reference/authentication/verify-sign-in) ### Keeping the session alive * The SDK keeps tokens in memory only. A restarted process must call `signIn()` again. Each sign-in uses a fresh nonce. * The access token lasts 15 minutes and the refresh token 7 days. When a call returns `401`, the SDK refreshes once with the refresh token, stores the new pair, and retries the call. You write no refresh code. * If that refresh fails (the refresh token expired or was revoked), the SDK clears the session and throws the `401`. Call `signIn()` again. * With cURL, refresh yourself: `POST /auth/token/refresh` with `{ "refreshToken": "..." }` and `x-app-id`, then use the new pair. See [Refresh access token](/api-reference/authentication/refresh-access-token). * In Node, a client holds the process open. Call `client.destroy()` when the bot is done. ## 2. Fund the bot Live mode only. In paper mode, skip to the balance check: the paper account starts with a simulated `PAPER_USD` balance. ```ts SDK theme={null} if (MODE === "live") { let deposit = await client.getDepositAddresses(); while (!deposit.ready) { await new Promise((r) => setTimeout(r, 2000)); deposit = await client.getDepositAddresses(); } console.log("send USDC to", deposit.evmAddress, "on", deposit.supportedChains); } const balances = await client.getManagedBalances({ mode: MODE }); console.log(balances.cash.map((c) => [c.tokenSymbol, c.availableRaw, c.decimals])); ``` ```bash cURL theme={null} # 202 { "ready": false } while addresses are created. Repeat until 200. curl -s $API/execution/deposit-addresses -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" curl -s "$API/execution/balances?mode=paper" -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ | jq '.cash[] | {tokenSymbol, availableRaw, decimals}' ``` [Get deposit addresses](/api-reference/funding/get-deposit-addresses) · [Get balances](/api-reference/portfolio/get-balances) · [Funding](/concepts/funding) `availableRaw` is an integer string in the token's decimals: `"5000000"` with 6 decimals is 5 USDC. ## 3. Find an outcome You trade an **outcome**, not a market. Take a `venueMarketOutcomes[].id`. ```ts SDK theme={null} const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5 }); const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id; if (!outcomeId) throw new Error("No open outcome found; try another search"); ``` ```bash cURL theme={null} curl -s "$API/venue-markets?status=open&search=bitcoin&limit=5" -H "x-app-id: $APP_ID" \ | jq '.data[] | {question, venue, outcomes: [.venueMarketOutcomes[] | {id, label}]}' export OUTCOME_ID=... ``` [List venue markets](/api-reference/markets/list-venue-markets) · [Markets, outcomes & matching](/concepts/markets) ## 4. Quote The bot's token makes the quote executable. Fill only when `status` is `"ok"`. ```ts SDK theme={null} const quote = await client.getSmartRoute({ venueMarketOutcomeId: outcomeId, tradeSide: "buy", maxSpend: 5, mode: MODE, }); if (quote.status !== "ok") throw new Error(`${quote.status}: ${quote.error ?? ""}`); ``` ```bash cURL theme={null} QUOTE=$(curl -s -G "$API/orderbook/$OUTCOME_ID/route" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "side=buy" --data-urlencode "maxSpend=5" --data-urlencode "mode=paper") echo "$QUOTE" | jq '{quoteId, status, error, expiresAt, totalCostIncFees}' export QUOTE_ID=$(echo "$QUOTE" | jq -r .quoteId) ``` [Get a quote (smart route)](/api-reference/trading/get-a-quote-smart-route) · [Quotes & smart routing](/concepts/quotes) A quote expires about 45 seconds after it is created. Fill right away. ## 5. Fill ```ts SDK theme={null} const fill = await client.executeManaged({ quoteId: quote.quoteId, mode: MODE }); ``` ```bash cURL theme={null} curl -s -X POST $API/execution/fill \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" -H "content-type: application/json" \ -d "{ \"quoteId\": \"$QUOTE_ID\", \"mode\": \"paper\" }" ``` [Execute quote](/api-reference/trading/execute-quote) The response means accepted, not done. If the call times out, do not re-quote: resend the same `quoteId` or poll status. Resending a `quoteId` never trades twice. See [Errors, retries & idempotency](/concepts/errors#idempotency-and-safe-retries). ## 6. Poll to a final state ```ts SDK theme={null} let status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE }); while (!status.terminal) { await new Promise((r) => setTimeout(r, status.pollAfterMs ?? 1000)); status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE }); } console.log(status.overallState, status.errorReason ?? ""); ``` ```bash cURL theme={null} until [ "$(curl -s -G $API/execution/status -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "quoteId=$QUOTE_ID" --data-urlencode "mode=paper" | tee /dev/stderr | jq -r .terminal)" = "true" ]; do sleep 1 done ``` [Get execution status](/api-reference/trading/get-execution-status) · [Order lifecycle & statuses](/concepts/order-lifecycle) `overallState` ends as `filled`, `partially_filled`, `failed`, `cancelled`, or `expired`. ## 7. Read positions ```ts SDK theme={null} const positions = await client.getExecutionPositions({ mode: MODE }); for (const p of positions.data) { console.log(p.venueMarket.question, p.venueMarket.venueMarketOutcomes.map((o) => [o.label, o.totalSize])); } client.destroy(); ``` ```bash cURL theme={null} curl -s "$API/execution/positions?mode=paper" -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" | jq ``` [Get user positions](/api-reference/portfolio/get-user-positions). When a market settles, claim winnings with [Claim winnings](/recipes/claim-winnings). ## Paper mode `mode: "paper"` on the quote, the fill, the status call, and the balance and position reads. Paper fills price against live books but never reach a venue, so no deposit is needed. A paper quote must be filled in paper mode. See [Paper trading](/recipes/paper-trading). To trade live, set `AGG_LIVE=1`, fund the deposit address from step 2, and drop `mode=paper` from the cURL calls. ## Failures a bot hits | Response | What to do | | - | - | | `401` with `code: "unregistered_domain"` on verify | `ORIGIN` is not one of the app's allowed origins. Add it under **Domains**. | | `401` `Invalid SIWE signature` | The message changed after signing, or the nonce was reused. Run `signIn()` again. | | `401` on any other call | The SDK already tried one refresh. Run `signIn()` again. | | `/auth/start` returns `type: "challenge_required"` | Bot protection is on and the app is past its user threshold. A headless bot cannot solve it. See [Bot protection](/recipes/bot-protection). | | `401` that names an API key | The app has **Require API key** on. Pass `apiKey` to `createAggClient` from a secret store. | | Quote `status` not `"ok"` | `insufficient_balance`: fund or lower `maxSpend`. `min_order_size_violated`: raise `maxSpend`. `insufficient_depth`, `no_orderbooks`: pick another outcome. | | `400` `quote_not_found` or `quote_expired` on fill | Poll status for the same `quoteId` first. Re-quote only after the steps in [Errors](/concepts/errors#idempotency-and-safe-retries). | | `400` `quote_already_executed` | Already filled. Poll status. | | `403` testing limit | The app reached its testing caps (20 users, 100 trades). See [Testing mode](/environments#testing-mode). | | `403` region | Trading is not available in the bot's country. See [Venue geo availability](/recipes/venue-geo-availability). | | `429` | Wait `Retry-After` seconds. The SDK error carries it as `retryAfterMs`. | The SDK throws `AggApiError` with `status`, `code`, and `retryAfterMs`; check it with `isAggApiError(err)`. Every code: [Errors, retries & idempotency](/concepts/errors). ## Full script Save as `bot.ts` and run with `AGG_APP_ID=... BOT_PRIVATE_KEY=0x... npx tsx bot.ts`. ```ts SDK theme={null} import { createAggClient, isAggApiError, MarketStatus } from "@agg-build/sdk"; import { privateKeyToAccount } from "viem/accounts"; async function main() { const ORIGIN = "https://bot.example.com"; // one of your app's allowed origins const MODE = process.env.AGG_LIVE === "1" ? "live" : "paper"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: process.env.AGG_APP_ID ?? "your-app-id", persistSession: false, }); const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as `0x${string}`); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); try { // 1. Sign in with the bot's key const start = await client.authStart({ provider: "siwe" }); if (start.type !== "nonce") throw new Error(`Expected a nonce, got ${start.type}`); const message = client.buildSiweMessage({ address: account.address, chainId: 1, nonce: start.nonce, statement: start.statement, domain: new URL(ORIGIN).host, uri: ORIGIN, }); await client.verify({ message, signature: await account.signMessage({ message }) }); // 2. Fund (live only) and check the balance if (MODE === "live") { let deposit = await client.getDepositAddresses(); while (!deposit.ready) { await sleep(2000); deposit = await client.getDepositAddresses(); } console.log("send USDC to", deposit.evmAddress, "on", deposit.supportedChains); } const balances = await client.getManagedBalances({ mode: MODE }); console.log(balances.cash.map((c) => [c.tokenSymbol, c.availableRaw, c.decimals])); // 3. Find an outcome const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5 }); const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id; if (!outcomeId) throw new Error("No open outcome found; try another search"); // 4. Quote const quote = await client.getSmartRoute({ venueMarketOutcomeId: outcomeId, tradeSide: "buy", maxSpend: 5, mode: MODE, }); if (quote.status !== "ok") throw new Error(`${quote.status}: ${quote.error ?? ""}`); // 5. Fill const fill = await client.executeManaged({ quoteId: quote.quoteId, mode: MODE }); // 6. Poll to a final state let status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE }); while (!status.terminal) { await sleep(status.pollAfterMs ?? 1000); status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE }); } console.log(status.overallState, status.errorReason ?? ""); // 7. Positions const positions = await client.getExecutionPositions({ mode: MODE }); for (const p of positions.data) { console.log(p.venueMarket.question, p.venueMarket.venueMarketOutcomes.map((o) => [o.label, o.totalSize])); } } catch (err) { if (isAggApiError(err)) console.error(err.status, err.code, err.message); throw err; } finally { client.destroy(); // lets Node exit } } main().catch(() => process.exit(1)); ``` ## Next > **[Direct orders](/recipes/direct-order-execution)** > > One call per trade with your own idempotent `externalId`. Live only. > **[Limit orders](/recipes/limit-orders)** > > Rest an order at your price on one venue. > **[Order book stream](/recipes/websocket-orderbook)** > > Live books over WebSocket instead of polling. > **[Self-custody](/recipes/self-custody)** > > Keep funds in the bot's own wallet; the bot signs each step. # Quickstart: React Source: https://docs.agg.market/quickstart/react Wire the AGG client, provider, sign-in, and a first trade into a React app > [!NOTE] > > **You'll need:** an app ID ([Base URL, app IDs & API keys](/environments)), your site's > origin in the app's allowed origins, and React 18 or later. > **Result:** a React app with AGG data, sign-in, and a buy that is tracked to a final state. Install the packages you need. See the [Packages overview](/packages/overview) for what each one does and its peer dependencies. Every layer uses the same client from `@agg-build/sdk`. ## 1. Create the client and provider Mount one `AggProvider` around your app. Use the hooks export for data hooks, or the UI export to include the UI kit's toast support; the UI export already includes the hooks provider. Mount `AggAuthProvider` inside it for the connect button. The example assumes you have a Wagmi configuration in `./wagmi-config` and your application component in `MyApp`. ```tsx theme={null} import "@agg-build/ui/styles.css"; import { AggProvider, QueryClient, QueryClientProvider } from "@agg-build/hooks"; import { createAggClient } from "@agg-build/sdk"; import { AggAuthProvider, ConnectButton, createGoogleAuthMethod } from "@agg-build/auth"; import { useSiweAuthMethod } from "@agg-build/auth/siwe"; import { WagmiProvider } from "wagmi"; import { wagmiConfig } from "./wagmi-config"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", wsUrl: "wss://ws.agg.market/ws", }); const queryClient = new QueryClient(); function AuthButton() { const siwe = useSiweAuthMethod({ statement: "Sign in" }); return ( ); } function App() { return ( ); } ``` For the UI kit's toasts, import `AggProvider` from `@agg-build/ui` instead of from `@agg-build/hooks`. Keep the same `client` and `config` props. That component already mounts the hooks provider. UI components go inside `MyApp`: ```tsx theme={null} import { EventMarketPage } from "@agg-build/ui/pages"; import { MarketDetails } from "@agg-build/ui/events"; import { LineChart } from "@agg-build/ui/primitives"; // Full event page: hero chart, market cards, orderbooks, all live // Or individual components ``` See the [Event Market Page](/components/pages/event-market-page) and [Market Details](/components/events/market-details) references, and [Customize UI](/components/customization) for fonts, colors, copy, and slots. Use outcome IDs for trading, charts, orderbooks, and live trades. Arbitrage and midpoint reads use market IDs. Some hook parameter names retain older naming; the table below lists the ID each one expects. ```tsx theme={null} import { useLiveMarket, useMarketArb, useMarketChart, useSmartRoute } from "@agg-build/hooks"; function MarketView({ venueMarketOutcomeId, venueMarketId, }: { venueMarketOutcomeId: string; venueMarketId: string; }) { const { orderbook } = useLiveMarket(venueMarketOutcomeId); const { data: chart } = useMarketChart({ marketId: venueMarketOutcomeId, interval: "5m", startTs: Date.now() - 86_400_000, endTs: Date.now(), }); const { data: route } = useSmartRoute({ venueMarketOutcomeId, tradeSide: "buy", maxSpend: 50, }); const { arbReturn } = useMarketArb(venueMarketId); const primaryVenue = chart?.primaryVenue; const candles = primaryVenue ? chart.venues[primaryVenue]?.candles ?? [] : []; return ; } ``` No session means a preview quote. See [Quotes](/concepts/quotes). | Hook | Id it actually reads | | - | - | | `useLiveMarket(canonicalMarketId)` | `venueMarketOutcomeId`. Not a `venueMarketId`. | | `useMarketOrderbook({ venueMarketOutcomeId })` | Outcome id. `marketId` is a deprecated alias for that same outcome id. | | `useMarketChart({ marketId, venueMarketIds })` | Both accept outcome IDs: pass `VenueMarketOutcome.id` values. | | `useLiveTrades(canonicalMarketId)` | Outcome id, because it calls `useLiveMarket`. | | `useSmartRoute({ venueMarketOutcomeId, tradeSide, maxSpend/sellShares })` | Outcome id. | | `useMarketArb(marketId)` | `venueMarketId`. Midpoints and arb use `venueMarketId`. | | `useArbFeed()` | Live arbitrage returns for many markets. | ## 2. Sign the user in `AuthButton` above lets the user sign in through `@agg-build/auth`. Trading calls use that user's session through the shared client. See the [Connect Button reference](/components/auth/connect-button) and [Authentication & sessions](/concepts/authentication) for every provider. ## 3. Buy and track to a final state The user needs a balance first. See [Funding](/concepts/funding) for deposits. No session means a preview quote. See [Quotes](/concepts/quotes). Then quote, fill, and poll the execution status until `terminal` is `true`. ```tsx theme={null} import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAggClient, useExecuteManaged, useSmartRoute } from "@agg-build/hooks"; function BuyButton({ venueMarketOutcomeId }: { venueMarketOutcomeId: string }) { const client = useAggClient(); const [quoteId, setQuoteId] = useState(null); const { data: quote } = useSmartRoute({ venueMarketOutcomeId, tradeSide: "buy", maxSpend: 5 }); const execute = useExecuteManaged({ onSuccess: (fill) => setQuoteId(fill.quoteId) }); const { data: status } = useQuery({ queryKey: ["execution-status", quoteId], queryFn: () => client.getExecutionStatus({ quoteId: quoteId! }), enabled: !!quoteId, // Poll at the interval the API suggests; stop once the trade is terminal. refetchInterval: (query) => query.state.data?.terminal ? false : (query.state.data?.pollAfterMs ?? 1000), }); if (status?.terminal) return

Done: {status.overallState}

; if (quoteId) return

Executing: {status?.overallState ?? "created"}

; return ( ); } ``` Stop polling when `terminal` is `true`. `overallState` then reads `filled`, `partially_filled`, `failed`, `cancelled`, or `expired`. Read [Order lifecycle & statuses](/concepts/order-lifecycle) for what each state means. ## WebSocket endpoint The SDK and hooks manage the socket, reconnection, resnapshots, and orderbook integrity checks. If you open raw sockets yourself, connect to: ``` wss://ws.agg.market/ws?appId=YOUR_APP_ID ``` See [WebSocket Protocol](/api/websocket). ## Next steps > **[Quickstart: REST](/quickstart/rest)** > > The same flow over REST, for backends and agents. > **[Order lifecycle](/concepts/order-lifecycle)** > > Every order status and how to track it. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Live orderbook rendering with the SDK, hooks, and UI. > **[Customize UI](/components/customization)** > > Brand AGG components with CSS variables, labels, formatting, and slots. # Quickstart: REST Source: https://docs.agg.market/quickstart/rest From zero to a filled order with curl: sign in, fund, find a market, quote, fill, and track > [!NOTE] > > **You'll need:** > > * An app ID. Sign in to the admin dashboard (`https://admin.agg.market`); your first sign-in > creates an app. Copy its ID from **API**. See [Base URL, app IDs & API keys](/environments). > * One allowed origin on that app, for example `https://app.example.com` (**Domains**). A new app > has none, and wallet sign-in is refused until you add one. The sign-in message must name it. > * `curl`, `jq`, and Foundry's `cast` (to create a test wallet and sign), or Node 18+ with > `@agg-build/sdk` and `viem` for the SDK tabs. > * A few dollars of USDC on a supported chain, or use [paper mode](#test-without-real-funds). > > **Result:** a filled order, polled until `terminal` is `true`. > [!WARNING] > > This guide places a real order on a live venue and spends real funds. Use a small amount, or add > `mode=paper` to trade without funds. Venues are restricted by the caller's country; see > [Venue geo availability](/recipes/venue-geo-availability). ## 0. Set up ```ts SDK theme={null} import { createAggClient, MarketStatus } from "@agg-build/sdk"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); const ORIGIN = "https://app.example.com"; // one of your app's allowed origins const account = privateKeyToAccount(process.env.PK as `0x${string}` ?? generatePrivateKey()); ``` ```bash cURL theme={null} export API=https://api.agg.market export APP_ID=your-app-id export ORIGIN=https://app.example.com # one of your app's allowed origins export DOMAIN=${ORIGIN#*://} # app.example.com # A throwaway test wallet. Keep the key out of source control. cast wallet new export PK=0x... # private key from the output export ADDRESS=$(cast wallet address --private-key $PK) # EIP-55 checksummed ``` ## 1. Sign the user in Get a nonce, sign an EIP-4361 (SIWE) message with the wallet, and exchange it for tokens. ```ts SDK theme={null} const start = await client.authStart({ provider: "siwe" }); if (start.type === "challenge_required") { throw new Error("Complete the bot-protection challenge, then restart sign-in with its token"); } if (start.type !== "nonce") throw new Error("Expected a wallet sign-in nonce"); const message = client.buildSiweMessage({ address: account.address, chainId: 1, nonce: start.nonce, domain: new URL(ORIGIN).host, uri: ORIGIN, }); const signature = await account.signMessage({ message }); await client.verify({ message, signature }); // the client now sends the token for you ``` ```bash cURL theme={null} NONCE=$(curl -s -X POST $API/auth/start \ -H "x-app-id: $APP_ID" -H "content-type: application/json" \ -d '{ "provider": "siwe" }' | jq -r .nonce) ISSUED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) MESSAGE=$(printf '%s wants you to sign in with your Ethereum account:\n%s\n\n\nURI: %s\nVersion: 1\nChain ID: 1\nNonce: %s\nIssued At: %s' \ "$DOMAIN" "$ADDRESS" "$ORIGIN" "$NONCE" "$ISSUED_AT") SIGNATURE=$(cast wallet sign --private-key $PK "$MESSAGE") export ACCESS_TOKEN=$(curl -s -X POST $API/auth/verify \ -H "x-app-id: $APP_ID" -H "content-type: application/json" \ -d "$(jq -n --arg m "$MESSAGE" --arg s "$SIGNATURE" '{message: $m, signature: $s}')" \ | jq -r .accessToken) ``` Full shapes on [Start authentication](/api-reference/authentication/start-authentication) and [Verify sign-in](/api-reference/authentication/verify-sign-in). `POST /auth/verify` returns `{ accessToken, refreshToken, user }`. Every call below sends `x-app-id` and `Authorization: Bearer $ACCESS_TOKEN`. The message format must be exact: a blank statement slot means two empty lines between the address and `URI:`. If sign-in fails: | Response | Fix | | - | - | | `401` with `code: "unregistered_domain"` | `DOMAIN` is not one of the app's allowed origins. | | `401` `Invalid SIWE signature` | The message text changed between signing and sending, the address is not checksummed, or the nonce was reused. See [Authentication & sessions](/concepts/authentication). | | `/auth/start` returns `"type": "challenge_required"` | Bot protection is on for the app. See [Bot protection](/recipes/bot-protection). | | `403` testing limit | The app reached 20 users in testing mode. | Access tokens are short-lived. If a later call returns `401`, see [Authentication & sessions](/concepts/authentication). ## 2. Get a deposit address and fund it ```ts SDK theme={null} let deposit = await client.getDepositAddresses(); while (!deposit.ready) { await new Promise((r) => setTimeout(r, 2000)); deposit = await client.getDepositAddresses(); } console.log(deposit.evmAddress, deposit.supportedChains); ``` ```bash cURL theme={null} # 202 { "ready": false } while addresses are created. Repeat until 200. curl -s $API/execution/deposit-addresses \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" | jq ``` Full shape on [Get deposit addresses](/api-reference/funding/get-deposit-addresses). The full `202 {ready:false}` loop is on [Funding](/concepts/funding). Send USDC to `evmAddress` on one of the chains in `supportedChains` (or to `svmAddress` on Solana). Then check the balance until `availableRaw` is above zero: ```ts SDK theme={null} const balances = await client.getManagedBalances(); console.log(balances.cash.map((c) => [c.tokenSymbol, c.availableRaw, c.decimals])); ``` ```bash cURL theme={null} curl -s $API/execution/balances \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ | jq '.cash[] | {tokenSymbol, availableRaw, decimals}' ``` `availableRaw` is an integer string in the token's decimals: `"5000000"` with 6 decimals is 5 USDC. Full shape on [Get balances](/api-reference/portfolio/get-balances). ## 3. Find a market List open markets and take an outcome id. You trade an **outcome**, not a market. ```ts SDK theme={null} const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5, }); const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id; if (!outcomeId) throw new Error("No open outcome found; try another search"); ``` ```bash cURL theme={null} curl -s "$API/venue-markets?status=open&search=bitcoin&limit=5" -H "x-app-id: $APP_ID" \ | jq '.data[] | {question, venue, outcomes: [.venueMarketOutcomes[] | {id, label}]}' export OUTCOME_ID=... # a venueMarketOutcomes[].id, for example the "Yes" outcome ``` Full shape on [List venue markets](/api-reference/markets/list-venue-markets). See [Markets, outcomes & matching](/concepts/markets) and [Ids](/concepts/ids) for how ids and cross-venue matching work. ## 4. Get a quote Send the user's token so the quote is executable, not a preview. ```ts SDK theme={null} const quote = await client.getSmartRoute({ venueMarketOutcomeId: outcomeId, tradeSide: "buy", maxSpend: 5, }); if (quote.status !== "ok") throw new Error(`${quote.status}: ${quote.error ?? ""}`); ``` ```bash cURL theme={null} QUOTE=$(curl -s -G "$API/orderbook/$OUTCOME_ID/route" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "side=buy" --data-urlencode "maxSpend=5") echo "$QUOTE" | jq '{quoteId, status, expiresAt, totalFilled, totalCostIncFees, fills: [.fills[] | {venue, avgPrice, venueQty}], warnings}' export QUOTE_ID=$(echo "$QUOTE" | jq -r .quoteId) ``` Full shape on [Get a quote (smart route)](/api-reference/trading/get-a-quote-smart-route). `message` is informational. Read `error` when `status` is not `"ok"`. Fill only when `status` is `"ok"`. Otherwise: | `status` | Fix | | - | - | | `insufficient_balance` | Fund the account (step 2) or lower `maxSpend`. | | `min_order_size_violated`, `insufficient_input_amount` | Raise `maxSpend`. | | `insufficient_depth`, `no_orderbooks` | Pick another market. | The quote expires at `expiresAt`, about 45 seconds after it is created. Fill within that window. Details: [Quotes & smart routing](/concepts/quotes). ## 5. Execute the quote ```ts SDK theme={null} const fill = await client.executeManaged({ quoteId: quote.quoteId }); ``` ```bash cURL theme={null} curl -s -X POST $API/execution/fill \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d "{ \"quoteId\": \"$QUOTE_ID\" }" | jq ``` Full shape on [Execute quote](/api-reference/trading/execute-quote). The response `status` is `pending`: accepted, not done. Poll [Order lifecycle](/concepts/order-lifecycle). If the call times out or returns 404, follow [Errors](/concepts/errors). Do not re-quote until that page says to. ## 6. Track it to a final state Poll the execution status until `terminal` is `true`, waiting `pollAfterMs` between calls. ```ts SDK theme={null} let status = await client.getExecutionStatus({ quoteId: fill.quoteId }); while (!status.terminal) { await new Promise((r) => setTimeout(r, status.pollAfterMs ?? 1000)); status = await client.getExecutionStatus({ quoteId: fill.quoteId }); } console.log(status.overallState, status.orders); ``` ```bash cURL theme={null} while :; do STATUS=$(curl -s -G $API/execution/status \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "quoteId=$QUOTE_ID") echo "$STATUS" | jq -c '{overallState, terminal}' [ "$(echo "$STATUS" | jq -r .terminal)" = "true" ] && break MS=$(echo "$STATUS" | jq -r '.pollAfterMs // 1000') SECS=$(( (MS + 999) / 1000 )) if [ "$SECS" -lt 1 ]; then SECS=1; fi sleep "$SECS" done echo "$STATUS" | jq '{overallState, errorReason, orders: [.orders[] | {venue, status, filledAmountRaw, actualSharesRaw, executionPriceRaw, txHash}]}' ``` Full shape on [Get execution status](/api-reference/trading/get-execution-status). The final `overallState` is one of: | `overallState` | Meaning | | - | - | | `filled` | Every order filled. Done. | | `partially_filled` | Some of the trade filled. Each order's `status` and `partialFillReason` say how much and why. | | `failed` | Nothing filled. Read `errorReason` and each order's `errorReason`. | | `cancelled`, `expired` | Nothing filled. | `filledAmountRaw` is USD spent and `actualSharesRaw` is shares bought, both as 6-decimal integer strings. Every status and state is listed in [Order lifecycle & statuses](/concepts/order-lifecycle). The new position shows up in `GET /execution/positions`. ## Test without real funds Add `mode=paper` to the quote, `"mode": "paper"` to the fill body, and `mode=paper` to the status call. Skip step 2: a paper account is created for the user on first use, with a simulated `PAPER_USD` balance. Paper fills price against live books but never reach a venue. See [Paper trading](/recipes/paper-trading). ## Next To expand this integration, use the [SDK method reference](/sdk/reference) to find the client method for each API operation. > **[Choose your integration](/integration-options)** > > Direct orders, limit orders, and self-custody. > **[Errors, retries & idempotency](/concepts/errors)** > > Every error code and which calls are safe to retry. > **[Webhooks](/recipes/webhooks/overview)** > > Get trade and deposit events pushed to your backend. > **[Base URL & API keys](/environments)** > > Configure your app and add a server API key. # Account Linking Source: https://docs.agg.market/recipes/account-linking Link additional auth providers to an already signed-in user > [!NOTE] > > Linking reuses the same redirect/callback machinery as > [Authentication](/recipes/authentication) — but it uses a **separate** set of > endpoints from sign-in. Do not use `authStart()` to link. > [!NOTE] > > This page is about linking multiple auth providers to one AGG account. If you need to link an > AGG user to your own partner-side user ID, use > [Partner External ID Linking](/recipes/external-id-linking). A user who is already signed in (e.g. with a wallet) can attach more providers — Google, Twitter, Apple, email, or an additional wallet (SIWE/SIWS) — to the **same** account. Because the caller is already authenticated, linking is a distinct flow from sign-in: it runs against `POST /users/me/link-account/*`. OAuth/email finish with a short-lived confirm token; wallet linking finishes with a signature over a server-issued challenge. In every case the new provider is bound to the current principal. ## The flow ``` client.linkAccount({ provider, redirectUrl }) → POST /users/me/link-account/start → redirect the browser to the provider → provider calls back to AGG, which redirects to your redirectUrl?link_confirm_token=… client.linkAccountConfirm(token) → POST /users/me/link-account/confirm → the Account row is written to the current user ``` > [!WARNING] > > Use `linkAccount()`, not `authStart()`. `authStart()` starts a fresh **sign-in** and cannot attach > a provider to the signed-in principal — pointing it at an authenticated session produces a > competing redirect, not a link. ## Example: link Google to a wallet account ```ts SDK theme={null} // User is already signed in with SIWE console.log(client.isAuthenticated); // true // Start the link — redirectUrl is REQUIRED and must be an allowed origin for your app const response = await client.linkAccount({ provider: "google", redirectUrl: "https://yourapp.com/auth/callback", }); if (response.type === "redirect") { window.location.href = response.url; // send the user to Google } ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/link-account/start \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "provider": "google", "redirectUrl": "https://yourapp.com/auth/callback" }' ``` [Start account linking](/api-reference/users/start-account-linking). OAuth start must run in the browser. The response sets an HttpOnly proof cookie the callback requires (`error=link_proof_invalid` on the redirect if it is missing). `linkAccount()` stores that cookie. The curl is the same body and headers, not a replacement for the browser call. After the provider callback, the browser lands back on your `redirectUrl` with a `link_confirm_token` query param. Exchange it for the linked account: ```ts SDK theme={null} // On the redirect target page const token = new URLSearchParams(window.location.search).get("link_confirm_token"); if (token) { await client.linkAccountConfirm(token); } ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/link-account/confirm \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "token": "LINK_CONFIRM_TOKEN" }' ``` [Confirm account linking](/api-reference/users/confirm-account-linking). ## React: this is handled for you If your app is wrapped in `` from `@agg-build/auth`, the confirm step runs automatically. On mount it reads `link_confirm_token` from the URL, calls `linkAccountConfirm()`, refreshes the user, and strips the token from the address bar — the same handler that finishes sign-in callbacks. ```tsx theme={null} // Anywhere under , kicking off the link: import { useLinkAccount } from "@agg-build/hooks"; const { startLink, isLoading, error } = useLinkAccount(); await startLink({ provider: "google", redirectUrl: window.location.href }); // …provider round-trip… on return, AggAuthProvider auto-confirms. ``` > [!WARNING] > > The page your `redirectUrl` lands on **must** have `` mounted (or call > [`useAggAuthCallback()`](/recipes/authentication#dedicated-callback-pages) on a dedicated callback > route). This is the same requirement as sign-in OAuth — if the auth provider isn't mounted where > the redirect returns, nothing consumes `link_confirm_token` and the link silently never completes. ## Email linking Email uses a magic link instead of an OAuth redirect. The result of `linkAccount` is a `magic_link` acknowledgement, not a redirect — tell the user to check their inbox: ```ts SDK theme={null} const response = await client.linkAccount({ provider: "email", email: "user@example.com", redirectUrl: "https://yourapp.com/auth/callback", }); // response = { type: "magic_link", success: true } — no redirect here. ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/link-account/start \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "provider": "email", "email": "user@example.com", "redirectUrl": "https://yourapp.com/auth/callback" }' ``` [Start account linking](/api-reference/users/start-account-linking). When the user clicks the emailed link, AGG redirects them to your `redirectUrl` with a `link_confirm_token`, and the same confirm step above completes the link. The confirm call is bound to the signed-in bearer, so the link must be opened in the browser where the user is still authenticated. ## Collisions `linkAccountConfirm()` resolves to `{ status: "linked" }` on first link and `{ status: "already_linked_same" }` if that identity was already attached to the **same** user (idempotent — safe to retry). If the provider identity already belongs to a **different** AGG user, the confirm call rejects with **HTTP 409** — catch it and tell the user the account is already in use elsewhere. ```ts theme={null} try { await client.linkAccountConfirm(token); } catch (err) { if (err.status === 409) { // This identity is linked to another account. } } ``` ## Wallet linking (SIWE/SIWS) A signed-in user can link an additional wallet (Ethereum or Solana) to their account. Unlike OAuth/email there is no redirect: `/start` returns a `wallet_challenge` containing a `message` the user signs **verbatim** with their wallet, then you submit the signature to `/confirm`. ### Prerequisite: the account needs a name for the challenge The challenge message names the signed-in account so the user can tell it is theirs before signing. If the account has nothing to name, `/start` fails closed with **HTTP 400 `no_identity_for_link_challenge`** instead of issuing an anonymous challenge. This applies to **wallet linking only** — OAuth and email linking never hit it, because neither builds a message for the user to sign. The server uses the first of these it finds: 1. `username` 2. a masked verified email from a linked account (`a***@example.com`) 3. a `displayName` from a linked account (e.g. a Google profile name) A wallet-only user — signed in with SIWE, no username, no OAuth provider linked — has none of the three, so **every wallet link attempt 400s until one exists**. **Mitigation: make sure a username is set before calling `linkAccount()`.** It is the only one of the three you can set directly. Set it with [Update username](/api-reference/users/update-username) (`client.updateUser({ username })`). 3-30 characters; letters, numbers, and `_ - .`; must start and end with a letter or number. A taken name returns 409. > [!NOTE] > > There is deliberately no server-side fallback here. The identity has to be something the user > **already had** before the link started — a value minted during the flow would be one the > attacker holds and the victim has nothing to check against. An app-name-only statement is no > better, since a phisher running the same app produces an identical one. ```ts SDK theme={null} // 1. Start the link — `address` is the wallet that will sign. const response = await client.linkAccount({ provider: "siwe", // or "siws" for Solana address: walletAddress, }); // response = { type: "wallet_challenge", message } ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/link-account/start \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "provider": "siwe", "address": "0x0000000000000000000000000000000000000000" }' ``` [Start account linking](/api-reference/users/start-account-linking). ```ts SDK theme={null} // 2. Have the user sign `message` EXACTLY as returned. Do not reconstruct or edit it. const signature = await signMessage(response.message); // 3. Confirm with the message + signature. await client.linkAccountConfirm({ kind: "wallet_signature", message: response.message, signature, }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/link-account/confirm \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "kind": "wallet_signature", "message": "MESSAGE", "signature": "SIGNATURE" }' ``` [Confirm account linking](/api-reference/users/confirm-account-linking). > [!WARNING] > > Sign the `message` **exactly** as returned — the server compares it byte-for-byte at confirm, so any > client-side reconstruction or edit fails. The message names the signed-in account (username, masked > email, or a linked handle) so the user can verify it is their own before signing. Wallet confirm returns the same statuses as OAuth/email: `linked` on first link, `already_linked_same` if that wallet was already attached to the **same** user (idempotent), and **HTTP 409** if the wallet is already linked to a **different** AGG user. ## Auto-linking by email If a user signs in with a provider that carries a verified email (e.g. Google) and another account already exists with that email, the accounts are linked automatically during sign-in — no explicit link step needed. ## Viewing linked accounts ```ts SDK theme={null} const profile = await client.getCurrentUser(); profile.accounts.forEach((account) => { console.log(`${account.provider}: ${account.providerAccountId}`); }); ``` ```bash cURL theme={null} curl https://api.agg.market/users/me \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` [Get current user](/api-reference/users/get-current-user). ## Disconnect a provider `DELETE /users/me/accounts/{provider}` removes one linked provider. `{provider}` is `wallet`, `solana_wallet`, `google`, `twitter`, `apple`, or `email` — not `siwe` or `siws`. Removing the last provider returns 400. When several accounts share that provider, the API requires query `providerAccountId` and returns 400 if you omit it. `disconnectAccount(provider)` does not send that query. ```ts SDK theme={null} await client.disconnectAccount("google"); ``` ```bash cURL theme={null} curl -X DELETE https://api.agg.market/users/me/accounts/google \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{}' ``` [Disconnect auth provider](/api-reference/users/disconnect-auth-provider). # aggKey Source: https://docs.agg.market/recipes/agg-key Optional fetch filter for rows whose key converges. Which id to store is on the Ids page. `aggKey` is an optional fetch filter. AGG computes it at discovery from that venue's own data, and you pass the string back unchanged to fetch the rows that share it. Which id to store is [Ids](/concepts/ids). It appears on both objects: `VenueEvent.aggKey` (the event, e.g. a game) and `VenueMarket.aggKey` (a market within it, e.g. a spread line). ## Using it Read `aggKey` off a market or event, then pass it back as a filter. The parameter accepts one key or several, and combines with the other filters (`venue`, `status`, …). Your app's venue and category visibility still applies. ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const { data } = await client.getVenueEvents({ aggKey: "agg_nba_nyk_sas_260610", }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/venue-events" \ -H "x-app-id: YOUR_APP_ID" \ --data-urlencode "aggKey=agg_nba_nyk_sas_260610" ``` [List venue events](/api-reference/markets/list-venue-events). A market key uses the same `aggKey` parameter on [List venue markets](/api-reference/markets/list-venue-markets). | Resource | API Reference | | - | - | | Event-level keys, such as `agg_nba_nyk_sas_260610` | [List Venue Events](/api-reference/markets/list-venue-events) | | Market-level keys, such as `agg_nba_nyk_sas_260610_sp_nyk_m10p5` | [List Venue Markets](/api-reference/markets/list-venue-markets) | Use the event key for every venue's version of the same event, the market key for the exact same contract including line, period and window. ## What it converges The key is computed from each venue's own data, so how far it converges depends on the family. It is **guaranteed identical across venues** where every venue exposes the same normalizable inputs: sports head-to-head (canonical league and team codes plus the game date), crypto up/down, and central-bank rate decisions (institution code plus meeting day). Two rows from different venues sharing a non-null key in those families are the same contract — same line, same period, same window. Dated outcomes — elections, "by when" questions, token launches — are derived from whatever the venue publishes: a human-readable slug where there is one, a ticker where there is not, and a date segment from that venue's own resolution timestamp. **Two venues listing the same question routinely produce different keys**, so use the [matched cluster](/recipes/matched-clusters) for that family. | Family | Prefix | Venues emitting it | Converges? | | - | - | - | - | | Sports head-to-head | `agg_{league}_…` | kalshi, limitless, novig, polymarket, predict | Guaranteed | | Crypto up/down | `agg_ud_` | kalshi, limitless, polymarket, predict | Guaranteed | | Central-bank rate decision | `agg_cb-` | hyperliquid, kalshi, limitless, myriad, polymarket, predict | Guaranteed | | Crypto price / threshold | `agg_px_` | hyperliquid, kalshi | Best-effort | | Index / equity threshold | `agg_idx_` | kalshi | Best-effort | | FX over/under | `agg_fx_` | kalshi | Best-effort | | Dated outcome | `agg_{subject}_{YYMMDD}` | every slug-bearing venue | No | A key carried by only one venue is usually not a convergence failure — most of the time only one venue lists that game or window. The guarantee is conditional: if two venues list the same contract, their keys agree. ## When it's `null` `null` means "no canonical key". It is not an error and does not mean the market is unmatched — those rows still carry their counterparts on `matchedVenueMarkets[]`. A market is keyed only when the key fully identifies the contract; see [Sports market suffixes](#sports-market-suffixes) for exactly which do. Player props, individual strikes of a multi-strike ladder, and long-tail venue-specific propositions return `null` even when their parent event has a key. Expect `null` far more often on markets than on events, and design for it rather than treating it as a gap. ## Do you need to parse it? Almost never. Every fact you might read out of the string is already a typed field, and those fields are the supported contract — the segment grammar is not. | What you need | Read this | Parse? | | - | - | - | | Every venue's row for a contract you hold | pass `aggKey` back as a filter, verbatim | No | | Which rows are the same event or market | `matchedVenueEvents[]`, `matchedVenueMarkets[]` | No | | Teams, league, kickoff | `title`, `sport`, `gameStartTime` | No | | The line's numeric value | `lineValue` | No | | The scope (half, quarter, set, innings) | `period` | No | | Moneyline vs spread vs total vs prop | `sportsMarketType` | No | | Which tab a market belongs in | `marketGroup` | No | | Ordering within a tab | `sectionRank`, `lineValue` | No | | One spread/total ladder card with a line selector | family key derived from `aggKey` | **Yes — the only case** | [Building a sports detail page](/recipes/sports-market-detail) takes exactly one parse: the ladder family key in [Grouping a line ladder](#grouping-a-line-ladder). If you are reaching into the key for anything else, the field you want already exists. ## Format A key is the `agg_` prefix followed by `_`-joined segments. Dates are `YYMMDD` (sports local date; daily markets) or a compact UTC instant (sub-daily intraday markets). Decimals use `p` (`2p5` = 2.5). A future format revision will use a new prefix (e.g. `aggk2_`), so `agg_` is your version guard. | Family | Format | Example | | - | - | - | | Sports head-to-head | `agg_{league}_{teamA}_{teamB}_{YYMMDD}[{suffix}]` — teams sorted; canonical per-league codes (NA tricodes, soccer country codes, folded surnames for tennis/UFC/esports); some competitions collapse to one token, e.g. FIBA regional qualifiers → `fibaq` | `agg_nba_nyk_sas_260610`, `agg_fifwc_bih_che_260618`, `agg_wta_eala_paolini_260706` | | Crypto up/down | `agg_ud_{asset}_{interval}_{startUTC}` — sub-daily carries the window-start instant, daily (`1d`) the day only | `agg_ud_btc_1h_260610T1500`, `agg_ud_btc_1d_260610` | | Crypto price / threshold | `agg_px_{asset}_{kind}_{instantUTC}` | `agg_px_btc_15m_260610T0600` | | FX over/under | `agg_fx_{pair}_{kind}_{instantUTC}` | see live responses | | Index / equity threshold | `agg_idx_{symbol}_{kind}_{instantUTC}` | see live responses | | Central-bank rate decision | `agg_cb-{bank}_{YYMMDD}` — the `cb-` namespace is deliberate: a bare `agg_fed_{YYMMDD}` would collide with the rate-**level** ladder already on that key | `agg_cb-fed_260916` | | Dated outcome | `agg_{normalized-subject}_{resolutionDate}` | `agg_us-recession-2026_261231` | ### Sports market suffixes The market key is the event key plus a suffix. A full-game two-way moneyline carries no suffix — its market key *is* the event key. This is the complete set. | Market | Suffix | Example | | - | - | - | | Moneyline — full game, 2-way single | none | `agg_ncaaf_cha_cita_260905` | | Moneyline — decomposed leg or draw | `_ml_{team}`, `_ml_draw` | `agg_afl_ade_bul_260905_ml_ade` | | Moneyline — period scoped | `_{period}_ml`, `_{period}_ml_{team}`, `_{period}_ml_draw` | `agg_bra2_juv_lon_260901_1h_ml_juv` | | Spread | `_sp_{team}_{m\|p}{line}[_{period}]` | `agg_arg_aae_cas_260904_sp_aae_m1p5` | | Total — whole match | `_tot_{line}[_{period}]` | `agg_arg_aae_cas_260904_tot_0p5_1h` | | Total — corners | `_tot_corners_{line}[_{period}]` | `agg_arg_aae_cas_260904_tot_corners_10p5` | | Total — one team | `_tot_team_{team}_{line}[_{period}]` | `agg_arg_aae_cas_260904_tot_team_aae_0p5` | | Total — one team's corners | `_tot_team_{team}_corners_{line}` | `agg_arg_aae_cas_260904_tot_team_aae_corners_2p5` | | Correct score | `_cs_{teamA}{scoreA}_{teamB}{scoreB}` — teams sorted, each followed by its own score | `agg_arg_aae_cas_260904_cs_aae0_cas0` | | Both teams to score | `_btts[_{period}]` | `agg_arg_aae_cas_260904_btts_1h` | | Team to advance | `_adv` (2-outcome single), `_adv_{team}` (decomposed legs) | `agg_lec_ame_mon_260902_adv` | | UFC — goes the distance | `_dist` | `agg_ufc_acosta_blaydes_260912_dist` | | UFC — method of ending | `_mend_{method}` | `agg_ufc_acosta_blaydes_260912_mend_kotko` | | UFC — method of victory | `_mvic_{method}_{fighter}` | `agg_ufc_acosta_blaydes_260912_mvic_kotko_acosta` | | UFC — round of victory | `_vicrnd_{round}_{fighter}` | kalshi-only, so it groups rather than converges | `{method}` is a closed set: `kotko`, `sub`, `dec`, `draw`. Full-game and full-match scopes carry no period token. Every other scope is the `period` value with non-alphanumeric characters removed (`set_1` → `set1`, `first_5_innings` → `first5innings`), trailing for spread/total/BTTS and prefixed for moneyline. > [!WARNING] > > **Spread lines carry a leg token**: `m{line}` when the named team gives points, `p{line}` when it > receives them — `_sp_cha_m19p5`, not `_sp_cha_19p5`. The stored `lineValue` sign is a venue label, > not an orientation. Code that strips a line with a bare `_\d+p\d+` pattern stops matching spread > keys — silently, producing one card per line instead of one ladder. ### Marker tokens are not unique **A team code can be spelled exactly like a suffix marker.** Tottenham is `tot`, Sporting is `sp`, Malmö is `ml`, and all three are live: | Key | What it is | | - | - | | `agg_epl_eve_tot_260912_ml_tot` | Everton vs **Tottenham** — Tottenham's moneyline leg | | `agg_atc_sp_wol_260906_sp_sp_m1p5` | **Sporting** vs Wolves — Sporting −1.5 | | `agg_ptc_ml_vlm_260830_tot_team_ml_corners_2p5` | **Malmö**'s corners total | Never split on the first `_sp_`, `_tot_` or `_ml_`. Splitting `agg_atc_sp_wol_260906_sp_sp_m1p5` on its first `_sp_` yields `agg_atc_sp` — no opponent, no date — which collides with every other Sporting fixture. Anchor on the `_YYMMDD` token: it ends every event key, and markers only ever appear after it. ```js theme={null} const DATE = /_\d{6}(?=_|$)/; // Everything up to and including the date token — the event base. const eventBase = (aggKey) => { const m = DATE.exec(aggKey); return m ? aggKey.slice(0, m.index + m[0].length) : aggKey; }; ``` ### Grouping a line ladder One card with a line selector, unioned across venues — the only supported structural read of the key. Uses the `eventBase` helper above, and gates on `sportsMarketType`: neither rule is safe applied to the other type. ```js theme={null} const familyKey = (market) => { const base = eventBase(market.aggKey); // Both legs of one symmetric spread → one family. They name DIFFERENT teams // (…_sp_arg_m1p5 and …_sp_che_p1p5 are the same line). if (market.sportsMarketType === "spread") return `${base}_sp`; // Drop the trailing line and optional period token. What remains keeps // subjects apart: …_tot, …_tot_team_arg, …_tot_corners. if (market.sportsMarketType === "total") return base + market.aggKey.slice(base.length).replace(/_\d+(p\d+)?(_[a-z0-9]+)?$/, ""); return market.aggKey; }; ``` Both rules drop the period, so take it from the `period` field and put it in your group key alongside the family. Order totals by `lineValue`. Order spreads by `|lineValue|`, taking the side of the axis from the `m`/`p` token — polymarket stores spreads negative while kalshi, novig and predict store them positive, so sorting on the signed value interleaves the two halves of one ladder. ## Related > **[Matched Clusters](/recipes/matched-clusters)** > > Where cluster fields live on the event, market, and outcome. > **[Ids](/concepts/ids)** > > Which id to store, and which id is only the anchor for this page. > **[Sports Market Detail](/recipes/sports-market-detail)** > > Compose discovery, orderbook and chart endpoints into a cross-venue game page. > **[Comparing Venue Prices](/recipes/comparing-venue-prices)** > > Fetch matched events and their midpoints to build a cross-venue price comparison. # Sign-in providers Source: https://docs.agg.market/recipes/authentication SIWS, Google, X, Apple, and email SDK calls, the React auth UI, and React Native token storage. Wallet message signing (SIWE) is in [Authentication & sessions](/concepts/authentication) and [Quickstart: REST](/quickstart/rest). Privy sign-in is [Privy Wallet Provider](/recipes/privy-wallet-provider). This page is the other provider calls, the React auth UI, and React Native token storage. Linking another provider onto a user who is already signed in is [Account linking](/recipes/account-linking). Attaching your own user id is [Partner External ID Linking](/recipes/external-id-linking). ## Google, X, Apple, and email These four calls use `client.authStart`. Google, X, and Apple return `type: "redirect"` and require `redirectUrl` (an allowed origin). Email sends a magic link; `email` is required and `redirectUrl` is where the link sends the user back. Email and wallet starts can return `type: "challenge_required"`. Resubmit the same body with `turnstileToken`. Google, X, and Apple do not return that type. Widget setup is [Bot protection](/recipes/bot-protection). ```ts theme={null} import { createAggClient, type AggAuthStartBody } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); // AggAuthStartBody requires `email` when provider is "email". async function startSignIn(body: AggAuthStartBody) { let start = await client.authStart(body); if (start.type === "challenge_required") { // Turnstile token for start.siteKey. See Bot protection. start = await client.authStart({ ...body, turnstileToken: "" }); } return start; } ``` **Google** ```ts theme={null} const response = await startSignIn({ provider: "google", redirectUrl: "https://yourapp.com/auth/callback", }); if (response.type === "redirect") { window.location.assign(response.url); } ``` **Twitter** ```ts theme={null} const response = await startSignIn({ provider: "twitter", redirectUrl: "https://yourapp.com/auth/callback", }); if (response.type === "redirect") { window.location.assign(response.url); } ``` **Apple** ```ts theme={null} const response = await startSignIn({ provider: "apple", redirectUrl: "https://yourapp.com/auth/callback", }); if (response.type === "redirect") { window.location.assign(response.url); } ``` **Email** ```ts theme={null} const response = await startSignIn({ provider: "email", email: "user@example.com", redirectUrl: "https://yourapp.com/auth/callback", }); // response.type === "magic_link" — the user opens the email. ``` [Start authentication](/api-reference/authentication/start-authentication). AGG then redirects to `redirectUrl` with a one-time `code`. Exchange it on your backend, or in the browser on that page. The code is single-use. ```ts SDK theme={null} const code = new URLSearchParams(window.location.search).get("code"); if (code) { await client.exchangeAuthCode(code); window.history.replaceState(null, "", window.location.pathname); } ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/auth/token/exchange \ -H "x-app-id: $AGG_APP_ID" \ -H "content-type: application/json" \ -d '{ "code": "AUTH_CODE" }' ``` [Complete redirect sign-in](/api-reference/authentication/complete-redirect-sign-in). ## SIWS Solana wallet sign-in does not redirect. Ledger uses this same call: `signMessage` on the raw UTF-8 bytes, not Phantom's `signIn()` helper. ```ts theme={null} import bs58 from "bs58"; import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); let start = await client.authStart({ provider: "siws" }); if (start.type === "challenge_required") { // Turnstile token for start.siteKey. See Bot protection. start = await client.authStart({ provider: "siws", turnstileToken: "" }); } if (start.type !== "nonce" || !start.nonce) { throw new Error("Unexpected auth start"); } const message = client.buildSiwsMessage({ address: walletPublicKey.toBase58(), chainId: "mainnet", nonce: start.nonce, domain: window.location.host, uri: window.location.origin, }); const signedBytes = await wallet.signMessage(new TextEncoder().encode(message)); const signature = bs58.encode(signedBytes); await client.verify({ message, signature }); ``` [Start authentication](/api-reference/authentication/start-authentication). [Verify sign-in](/api-reference/authentication/verify-sign-in). ## React `AggAuthProvider` from `@agg-build/auth` completes `challenge_required` for the connect button. See [Bot protection](/recipes/bot-protection) and the [Connect Button reference](/components/auth/connect-button). Wire the client in [Quickstart: React](/quickstart/react). ```tsx theme={null} import { QueryClient, QueryClientProvider } from "@agg-build/hooks"; import { createAggClient } from "@agg-build/sdk"; import { AggProvider } from "@agg-build/ui"; import { AggAuthProvider, ConnectButton, createEmailAuthMethod, createGoogleAuthMethod, } from "@agg-build/auth"; import { useSiweAuthMethod } from "@agg-build/auth/siwe"; import { WagmiProvider } from "wagmi"; import { wagmiConfig } from "./wagmi-config"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); const queryClient = new QueryClient(); function AuthButton() { const siwe = useSiweAuthMethod({ statement: "Sign in to AGG", }); return ( ); } export function App() { return ( ); } ``` `createTwitterAuthMethod` and `createAppleAuthMethod` are the same shape as `createGoogleAuthMethod`. ### Dedicated callback pages On a standalone callback route, `useAggAuthCallback()` reads the redirect: ```tsx theme={null} import { useAggAuthCallback } from "@agg-build/auth"; export function AuthCallbackPage() { const { error, isHandled, isHandling, user } = useAggAuthCallback(); if (isHandling) return

Finishing sign-in...

; if (error) return

{error.message}

; if (isHandled) return

Signed in as {user?.id}

; return

No AGG auth callback data was found.

; } ``` `useAggAuth` is the wallet hook for a custom button. ```tsx theme={null} import { useAggAuth } from "@agg-build/hooks"; import { useAccount, useSignMessage } from "wagmi"; function SignIn() { const { address, chainId } = useAccount(); const { signMessageAsync } = useSignMessage(); const { signIn, signOut, isAuthenticated, user, isLoading } = useAggAuth({ address, chainId, signMessage: async (message) => signMessageAsync({ message }), }); if (isAuthenticated) { return (

Signed in as {user.id}

); } return ( ); } ``` ```tsx theme={null} import bs58 from "bs58"; import { useWallet } from "@solana/wallet-adapter-react"; import { useAggAuth } from "@agg-build/hooks"; function SolanaSignIn() { const { publicKey, signMessage } = useWallet(); const { signIn, isLoading } = useAggAuth({ address: publicKey?.toBase58(), chain: "solana", chainId: "mainnet", signMessage: async (message) => { const signedBytes = await signMessage!(new TextEncoder().encode(message)); return bs58.encode(signedBytes); }, }); return ( ); } ``` ## Token storage Access and refresh tokens stay in memory. The SDK writes only a non-sensitive hint to `localStorage` (user id, username, avatar, wallet address, and `wasAuthenticated`), keyed by `appId`. Restoring that hint sets `authStatus` to `"unknown"`, not `"authenticated"`. `persistSession: false` skips the hint. Refresh lifetimes are in [Authentication & sessions](/concepts/authentication#refresh-a-session). | Mode | Refresh token | Survives reload | | - | - | - | | `"body"` (default) | Returned by `verify()` and `exchangeAuthCode()` | No — you store it | | `"cookie-refresh"` | HttpOnly cookie on the AGG API host, scoped to `/auth` | Yes — silent refresh on cold start | In `"cookie-refresh"`, those methods may omit `refreshToken` from the JSON. ### React Native `localStorage` is missing, so the hint is not stored and `isAuthenticated` is `false` after a restart. The HttpOnly cookie is not available either. Keep `"body"` mode, store the refresh token in the platform keystore, and seed it with `setSession()` before the first authenticated call. ```ts theme={null} import * as SecureStore from "expo-secure-store"; // session is the result of verify() or exchangeAuthCode() in "body" mode. if (session.refreshToken) { await SecureStore.setItemAsync("agg_refresh", session.refreshToken); } const refreshToken = await SecureStore.getItemAsync("agg_refresh"); if (refreshToken) { client.setSession({ accessToken: "", refreshToken }); await client.refreshAccessToken(); } ``` Do not put the refresh token in `AsyncStorage`. ## Install ```bash theme={null} npm install @agg-build/sdk @agg-build/hooks @agg-build/ui @agg-build/auth npm install wagmi npm install @solana/wallet-adapter-react bs58 ``` Install wagmi only for Ethereum, and the Solana packages only for SIWS. ## Related > **[Authentication](/concepts/authentication)** > > Sessions, refresh, and SIWE. > **[Quickstart: REST](/quickstart/rest)** > > Wallet sign-in with curl. > **[Bot protection](/recipes/bot-protection)** > > Turnstile when start returns challenge\_required. > **[Privy](/recipes/privy-wallet-provider)** > > Sign in with a Privy access token. > **[Account linking](/recipes/account-linking)** > > Add a provider to the current user. > **[Quickstart: React](/quickstart/react)** > > Client, provider, and connect button setup. # Avatar Upload Source: https://docs.agg.market/recipes/avatar-upload Upload and set a user avatar with presigned S3 URLs > [!NOTE] > > Avatar endpoints require a signed-in user session. See [Authentication](/recipes/authentication) > for the initial sign-in flow. Avatar upload is a two-step process: request a presigned upload URL, then upload the file directly to S3. The URL is valid for 10 minutes. After upload, the image is processed to WebP. Confirm with `client.updateUser({ confirmAvatar: true })` (`PATCH /users/me`), or poll until `avatarUrl` is on the profile. ## Step 1: Get Upload URL `contentType` is `image/jpeg`, `image/png`, `image/gif`, or `image/webp`. ```ts SDK theme={null} const contentType = "image/jpeg"; const { uploadUrl, assetKey, cdnUrl } = await client.createAvatarUploadUrl(contentType); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/avatar \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "contentType": "image/jpeg" }' ``` [Get avatar upload URL](/api-reference/users/get-avatar-upload-url). ## Step 2: Upload the Image Upload the raw file bytes directly to the presigned URL. The `Content-Type` on the S3 PUT must be the type you presigned. ```ts theme={null} const file = inputElement.files[0]; await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: file, }); ``` ## Step 3: Confirm Confirming is `PATCH /users/me` with `{ "confirmAvatar": true }`, which is `client.updateUser({ confirmAvatar: true })`. Retry while the call returns `400` (not processed yet) until `avatarUrl` is set. ```ts SDK theme={null} async function confirmAvatar(maxAttempts = 10) { for (let i = 0; i < maxAttempts; i++) { try { const profile = await client.updateUser({ confirmAvatar: true }); if (profile.avatarUrl) { return profile.avatarUrl; } } catch (err: any) { if (err.status !== 400) throw err; } await new Promise((r) => setTimeout(r, 1000)); } throw new Error("Avatar processing timed out"); } ``` ```bash cURL theme={null} curl -X PATCH https://api.agg.market/users/me \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "confirmAvatar": true }' ``` [Update username](/api-reference/users/update-username). The same call sets `username` and `removeAvatar`. If you prefer not to confirm explicitly, poll [Get current user](/api-reference/users/get-current-user) until `avatarUrl` appears: ```ts theme={null} async function waitForAvatar(maxAttempts = 10) { for (let i = 0; i < maxAttempts; i++) { const profile = await client.getCurrentUser(); if (profile.avatarUrl) { return profile.avatarUrl; } await new Promise((r) => setTimeout(r, 1000)); } throw new Error("Avatar processing timed out"); } const avatarUrl = await waitForAvatar(); ``` ## Constraints | Constraint | Value | | - | - | | Max file size | 5 MB | | Allowed types | JPEG, PNG, GIF, WebP | | Processing time | Usually 1-3 seconds | | CDN variants | 44px, 48px, 60px, original, raw | ## Removing an Avatar Same [Update username](/api-reference/users/update-username) call, with `removeAvatar: true`. ```ts SDK theme={null} await client.updateUser({ removeAvatar: true }); ``` ```bash cURL theme={null} curl -X PATCH https://api.agg.market/users/me \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "removeAvatar": true }' ``` ## Error Handling ```ts theme={null} try { const { uploadUrl } = await client.createAvatarUploadUrl(file.type); const uploadRes = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!uploadRes.ok) { throw new Error(`Upload failed: ${uploadRes.status}`); } const avatarUrl = await confirmAvatar(); console.log("Avatar ready:", avatarUrl); } catch (err: any) { if (err.status === 415) { console.error("Unsupported file type"); } else { console.error("Avatar upload failed:", err); } } ``` # Bot Protection Source: https://docs.agg.market/recipes/bot-protection Shield any user action with Cloudflare Turnstile challenges and multi-layer rate limiting > [!NOTE] > > **Bot protection is enabled by default on every new app.** A Cloudflare Turnstile widget is > provisioned automatically at app-creation time (provided the app has at least one > `allowedOrigin`), so you can start using it immediately — no setup required. This guide > covers the full protection model, the standalone `/bot-protection/*` endpoints you can use > to gate any action in your product, and how to render the Turnstile widget in your frontend. ## Overview AGG combines global identity quotas, scoped read quotas, sign-in limits, and Turnstile challenges. Handle these response shapes in your client: * **`429 Too Many Requests`** — back off and retry after the server-provided delay. * **`503 Service Unavailable`** — scoped rate-limit enforcement is unavailable; honor its retry hint. * **`challenge_required`** — render a Turnstile widget, collect the token, and resubmit. | Layer | Purpose | Client-visible outcome | | - | - | - | | 1 | Per-IP rate limit across every endpoint | `429 Too Many Requests` | | 2 | Per-user-in-app or per-app/IP identity limit | `429 Too Many Requests` | | 3 | Tighter rate limit on `POST /auth/start` | `429 Too Many Requests` | | 4 | Cloudflare Turnstile challenge on sign-in | `challenge_required` → `403` on invalid token | Market-data reads also have automatic [route-family quotas](/api/market-data), including an outcome-unit budget shared by the single and batch outcome snapshot routes. Layers 1–3 are automatic: they run before your request reaches any handler and return a standard `429` you can retry. Layer 4 is the Turnstile challenge, which you integrate once and reuse anywhere in your product. ## Rate limits ### Layer 1 — Per-IP limit | Property | Value | | - | - | | Scope | All endpoints | | Default | **1,000 requests / 60 seconds** per IP | | Response | `429 Too Many Requests` with `Retry-After` header (seconds) | ```http theme={null} HTTP/1.1 429 Too Many Requests Retry-After: 37 Content-Type: application/json { "message": "Too many requests. Retry after 37s." } ``` ### Layer 2 — Browser identity or validated API key | Property | Value | | - | - | | Scope | Authenticated app/user identity, or app/IP identity for anonymous browser requests | | Default | **18,000 requests / 60 seconds** per identity | | Response | `429 Too Many Requests` with `Retry-After` header | This is not a shared limit for every user of an app. Browser requests must satisfy both the IP and identity limits. A validated API key uses its own limit instead, with an 18,000-request default or a configured per-key quota, and is exempt from the browser IP limit. New [market-data route caps](/api/market-data) still apply to that key and are not bypassed by a higher per-key quota. If scoped market-data limits cannot be enforced, those routes return `503` with `Retry-After: 1` and `Cache-Control: private, no-store`; retry after the hint. ### Layer 3 — Sign-in limit A tighter per-IP limit applied only to `POST /auth/start`. Authentication endpoints are high-value targets for credential stuffing, nonce mining, and email enumeration, so they warrant stricter throttling. | Property | Value | | - | - | | Scope | `POST /auth/start` only | | Default | **20 requests / 60 seconds** per IP | | Response | `429 Too Many Requests` with `retryAfter` in the response body | ```http theme={null} HTTP/1.1 429 Too Many Requests Content-Type: application/json { "message": "Too many requests. Retry after 12s.", "retryAfter": 12 } ``` ### Handling `429` on the client Always honor the server's retry hint: `Retry-After` header for Layers 1 and 2, `retryAfter` body field for Layer 3. Never hardcode a fixed delay. ```typescript theme={null} async function authStartWithBackoff( body: AuthStartBody, attempt = 0, ): Promise { try { return await client.authStart(body); } catch (err) { if (isRateLimitError(err) && attempt < 3) { const waitMs = (err.retryAfter ?? 2 ** attempt) * 1000; await new Promise((resolve) => setTimeout(resolve, waitMs)); return authStartWithBackoff(body, attempt + 1); } throw err; } } ``` A burst of `429` responses during normal use typically indicates a tight retry loop in your client. Debounce the caller and surface a visible "please wait" state after the second consecutive `429`. ## Turnstile challenges Layer 4 activates when all of the following are true: 1. The sign-in provider is `siwe`, `siws`, or `email` (OAuth providers are exempt). 2. The app has `botProtectionEnabled = true`. 3. The app's registered user count is at or above `botProtectionThreshold`. 4. A Turnstile widget is linked to the app. When activated, `POST /auth/start` returns `{ type: "challenge_required", siteKey }` instead of the usual nonce or magic link. Your client renders the Turnstile widget, collects the resulting token, and retries `/auth/start` with `turnstileToken` included in the body. ```mermaid theme={null} sequenceDiagram participant C as Client participant A as AGG API participant CF as Cloudflare C->>A: POST /auth/start { provider: "siwe" } A-->>C: 200 { type: "challenge_required", siteKey } C->>CF: render widget + user solves CF-->>C: turnstileToken C->>A: POST /auth/start { provider: "siwe", turnstileToken } A->>CF: verify token CF-->>A: valid A-->>C: 200 { type: "nonce", nonce } Note over C,A: continue the sign-in on Quickstart REST ``` When `type` is `challenge_required`, render the widget and send the same start again with `turnstileToken`. Then continue [Quickstart: REST](/quickstart/rest). The same retry applies to `siws` and `email`. Wallet message signing stays on that quickstart. ### Response matrix — `POST /auth/start` | Scenario | HTTP | Body | | - | - | - | | Below threshold, or bot protection disabled | `200` | `{ type: "nonce" \| "magic_link" \| ... }` | | Challenge required, no `turnstileToken` submitted | `200` | `{ type: "challenge_required", siteKey }` | | Challenge required, valid `turnstileToken` | `200` | `{ type: "nonce" \| "magic_link" \| ... }` | | Challenge required, invalid or reused token | `403` | `{ message: "Bot protection verification failed." }` | | Rate limit breached | `429` | `{ message: "Too many requests. Retry after Xs.", retryAfter }` | ## Standalone bot protection Use the same widget to gate any action. Two calls: [fetch the site key](/api-reference/authentication/fetch-turnstile-site-key) from the browser, then [verify the token](/api-reference/authentication/verify-turnstile-token) from your server. ```bash theme={null} curl https://api.agg.market/bot-protection/site-key \ -H "x-app-id: $APP_ID" ``` Verify requires both `x-app-id` and `x-app-api-key`. The server checks that `x-app-id` matches the app id embedded in the key. An API key alone is not enough. Tokens are single-use. ```ts SDK theme={null} // No SDK method. Call this from your server, never from the browser. const verifyRes = await fetch("https://api.agg.market/bot-protection/verify", { method: "POST", headers: { "x-app-id": process.env.AGG_APP_ID!, "x-app-api-key": process.env.AGG_APP_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ turnstileToken }), }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/bot-protection/verify \ -H "x-app-id: $APP_ID" \ -H "x-app-api-key: $AGG_APP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "turnstileToken": "TOKEN" }' ``` Request fields are on [Verify Turnstile token](/api-reference/authentication/verify-turnstile-token). ### Example — gating any action Site key lookup happens in the browser. Token verification happens on your server, with both headers. **Frontend — collect the token and post it to your own backend:** ```typescript theme={null} async function submitGatedAction(payload: ActionPayload) { // 1. Fetch the site key once — safe to call from the browser with just x-app-id. const { siteKey } = await fetch("https://api.agg.market/bot-protection/site-key", { headers: { "x-app-id": APP_ID }, }).then((r) => r.json()); if (!siteKey) { // No widget registered — proceed without a challenge. return submit(payload); } // 2. Render the widget and collect a token. const turnstileToken = await renderTurnstileWidget({ siteKey }); // 3. Post the token + payload to YOUR backend (never call /bot-protection/verify directly). const res = await fetch("/api/my-gated-action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, turnstileToken }), }); if (!res.ok) throw new Error("Bot protection verification failed"); return res.json(); } ``` > [!NOTE] > > Need an API key, or the rules for `x-app-api-key` (scopes, rotation, `requireApiKey`, per-key limits)? See > [Server API Keys](/environments#server-api-keys). > [!WARNING] > > Each Turnstile token can only be verified **once**. If you pre-verify a token against > `/bot-protection/verify` and then submit the same token to another endpoint, the second > call will fail. For any given user action, collect a fresh token from the widget before > submitting. ## Rendering the Turnstile widget Rendering the widget is a three-step process on the client: 1. **Load the Turnstile script** — Cloudflare's loader (`api.js`) exposes a global `window.turnstile` object with the widget API. 2. **Mount the widget into a DOM element** — pass the `siteKey` fetched from `GET /bot-protection/site-key` (or the `siteKey` returned in a `challenge_required` response). 3. **Collect the token in a callback** — the widget fires a callback with a short-lived token once the user passes the challenge. Submit that token alongside the user's action. ### Step 1 — Load the Turnstile script Add the loader to your document `` once, on every page that might render a widget. The `async defer` attributes keep it from blocking initial render: ```html theme={null} ``` In a React/Next.js app, load it once in your root layout (e.g. `_app.tsx` or `layout.tsx`) using Next's ` ``` Use this when the widget lives on a static page (a sign-in form, a contact form, etc.) and you're happy to read the token from a callback. ### Step 3 — Programmatic rendering (React) For SPAs — where the widget is mounted and unmounted as the user navigates — use the programmatic API. Call `window.turnstile.render()` to mount, `reset()` when the user retries, and `remove()` on unmount so the widget doesn't leak across route changes. ```tsx theme={null} import { useEffect, useRef, useState } from "react"; declare global { interface Window { turnstile?: { render: ( el: HTMLElement, opts: { sitekey: string; callback: (token: string) => void; "error-callback"?: () => void; "expired-callback"?: () => void; }, ) => string; reset: (widgetId: string) => void; remove: (widgetId: string) => void; }; } } export function useTurnstile(siteKey: string | null) { const containerRef = useRef(null); const widgetIdRef = useRef(null); const [token, setToken] = useState(null); useEffect(() => { if (!siteKey || !containerRef.current || !window.turnstile) return; widgetIdRef.current = window.turnstile.render(containerRef.current, { sitekey: siteKey, callback: (t) => setToken(t), "expired-callback": () => setToken(null), "error-callback": () => setToken(null), }); return () => { if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current); }; }, [siteKey]); const reset = () => { setToken(null); if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current); }; return { containerRef, token, reset }; } ``` Wire it into a form component: ```tsx theme={null} function SignInForm() { const [siteKey, setSiteKey] = useState(null); const { containerRef, token, reset } = useTurnstile(siteKey); useEffect(() => { fetch("https://api.agg.market/bot-protection/site-key", { headers: { "x-app-id": APP_ID } }) .then((r) => r.json()) .then(({ siteKey }) => setSiteKey(siteKey)); }, []); const handleSubmit = async () => { if (!token) return; // widget hasn't produced a token yet try { await client.authStart({ provider: "siwe", turnstileToken: token }); } catch (err) { reset(); // on failure, reset so the user can solve again throw err; } }; return (
); } ``` ### Token lifetime and reset behavior Tokens are short-lived and single-use: * **5-minute TTL** — Cloudflare invalidates a token 5 minutes after the user solves the challenge. * **Single-use** — AGG enforces one-time verification. A token consumed by `/auth/start` or `/bot-protection/verify` cannot be reused. * **Always collect a fresh token right before the submit action**, not at page load. * **On a failed submit, call `turnstile.reset()`** so the user can produce a new token without a full page reload. Cloudflare also fires `expired-callback` if the user leaves the widget idle too long — handle it by clearing your local token state so the submit button re-disables until the user re-solves. ## Troubleshooting | Symptom | Likely cause | | - | - | | `403 Bot protection verification failed.` on first submit | Token expired (over 5 minutes since solve), or the widget domain is not registered for this app. | | `403` on a follow-up submit with the same token | Token was already consumed — collect a fresh one from the widget. | | `400 Bot protection is not configured for this app.` | The app has no Turnstile widget linked. Contact AGG support to configure bot protection for the app. | | `429` on repeated `/auth/start` calls | Layer 3 sign-in rate limit. Back off and retry after the `retryAfter` interval. | | Widget never renders in the browser | No widget is registered for the app — `GET /bot-protection/site-key` returns `{ siteKey: null }`. | ## Related > **[Authentication](/recipes/authentication)** > > Full sign-in flow for SIWE, SIWS, email, and OAuth providers. > **[Token Refresh](/concepts/authentication#refresh-a-session)** > > Renew access tokens after a successful sign-in. # Builder Codes Source: https://docs.agg.market/recipes/builder-codes Attribute the orders your app sends to a venue back to your own account Venues let a front-end register its own identity so the order flow it sends can be attributed back to it. Set one value per venue, per app, and AGG applies it when your users trade. ## Configuring Open the admin dashboard → select your app → **Settings → Builder Codes**. Each venue has its own card showing the current value, when it was last updated, and usage counters. | Venue | Slug | What you enter | Format | | - | - | - | - | | Polymarket | `polymarket` | Builder code | `0x` + 64 hex chars (bytes32). All-zero is rejected. | | Hyperliquid | `hyperliquid` | Builder address | `0x` + 40 hex chars (EVM address). Stored lower-cased. | | Predict.fun | `predict` | Referral code | Exactly 5 characters. | Only the **owner** of an app can set, update, or remove a value. ## Where to get each value **Polymarket** — create a builder profile at `polymarket.com/settings?tab=builder`. The profile must be registered and set both its maker and taker fee rate to `0`, or the code will be rejected when you save it. **Hyperliquid** — the EVM address you want Hyperliquid to credit for the order flow. **Predict.fun** — the 5-character referral code from your Predict.fun account. You need to deposit at least \$10 to Predict.fun before they will issue you one. ## What happens after you save On Polymarket (`polymarket`) and Hyperliquid (`hyperliquid`), AGG includes your builder identity when it submits that user's order to the venue. This is venue attribution. It is not `appFeeBips`, and it does not change the fee on the quote. Self-custody Hyperliquid asks the user's wallet once for the builder-fee approval. See [Signing](/recipes/self-custody-signing). On Predict.fun (`predict`) there is no per-order field. The referral is attached to the trader's Predict.fun account, once, on their first order through your app. It only lands for traders who don't already have a referral on their account, so rotating your code later affects new traders only. A value that a venue rejects never blocks a trade — the order still goes through. ## Hyperliquid referral code (optional) The Hyperliquid card also takes your Hyperliquid **referral code**, next to the builder address. Hyperliquid only issues one to an account with \$10k of trading volume. We attach it once to each of your users' Hyperliquid accounts: the user gets Hyperliquid's referral fee discount, and your account earns Hyperliquid's referral share of their fees, which you claim on Hyperliquid. * Accounts that are already referred by anyone — some wallets attach their own code — keep their existing referrer. Hyperliquid does not allow changing it. * The account must have received a deposit before the code can attach. If it can't attach yet, we try again on the user's next order. * Codes are case-sensitive. A code Hyperliquid doesn't recognise shows as an error on the card and is paused for an hour; trading is never affected. Once you register that code on Hyperliquid, your users are picked up again automatically. * **Self-custody:** the referral is one extra `hl_l1_action` signature request (`purpose: "hl_referral"`, `optional: true`), parked after the order's and signed by the same key (your agent key, if you use one). It is asked for at most once per user, and only for users who can still be referred. It is optional: if your signer declines it or it expires, skip it and post the rest — `fillSelfCustody` in `@agg-build/sdk` does this for you — and the trade goes through as normal. The user is asked again on their next trade. ## Removing **Remove** on the venue card deletes the value. On Predict.fun this stops new traders being referred but does not un-refer traders already attributed to you. # Building Market Views Source: https://docs.agg.market/recipes/building-market-views List events, open one with bootstrap, then markets, the orderbook socket, and a quote. Live discovery and orderbook reads include active venues only. Retired venue identifiers can still appear on historical positions, activity, and claim flows. Build the screen in this order: list events, load an event, page its markets, then subscribe to prices. See [Identifiers](/concepts/ids) for the IDs used at each step. ``` VenueEvent └── venueMarkets[] ← this venue's markets, not the other venues ├── venueMarketOutcomes[] └── matchedVenueMarkets[] ← the same market on the other venues ``` Sibling venues are `matchedVenueMarkets[]`. They are not extra rows inside the anchor's `venueMarkets[]`. Field placement is [Matched clusters](/recipes/matched-clusters). ## List An unfiltered list returns one row per matched cluster. The embedded `venueMarkets` array is a preview of up to three markets and is deprecated. Do not render counts from its length. Use `marketCount`, or the market list below. Prices are not on this response. ```ts SDK theme={null} import { createAggClient, MarketStatus } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const { data: events } = await client.getVenueEvents({ status: [MarketStatus.open], }); ``` ```bash cURL theme={null} curl -H "x-app-id: YOUR_APP_ID" \ "https://api.agg.market/venue-events?status=open" ``` [List venue events](/api-reference/markets/list-venue-events). For a price on the card, collect `venueMarketId` values and call `getMidpoints`. The client batches 75 ids. Keep each request under the URL limit on [Get live midpoints](/api-reference/market-data/get-live-midpoints-for-multiple-markets). See [Comparing venue prices](/recipes/comparing-venue-prices) for an example. There is no midpoint socket. Filters on the list include `aggKey`, `grouped`, `externalIdentifier`, and `recurrence`. `externalIdentifier` also requires `venue`. ## Open an event Open the event with bootstrap. The first slice is not every market. Delivery, charts, and search choices are on [Market-data API](/api/market-data#bootstrap). ```ts SDK theme={null} const view = await client.getEventBootstrap("EVENT_ID"); ``` ```bash cURL theme={null} curl -H "x-app-id: YOUR_APP_ID" \ "https://api.agg.market/venue-events/EVENT_ID/bootstrap" ``` [Get the first useful event view](/api-reference/markets/get-the-first-useful-event-view). [Get venue event](/api-reference/markets/get-venue-event) is the event without that slice. Its embedded `venueMarkets` array is deprecated. ## Markets The full market list is `GET /venue-markets?venueEventId=`. `cluster=true` (the default) covers the matched cluster: every venue's markets, siblings folded onto the anchor row, and `matchedVenueMarkets[]` still on each row. `cluster=false` is that one venue event. Only the strings `true` and `false` are accepted. Pass the same flag to `GET /venue-events/{id}` when `marketCount` has to match the list. [List venue markets](/api-reference/markets/list-venue-markets). Market filters include `aggKey`, `cluster`, and `externalIdentifier` (`venue` is required with `externalIdentifier`). Event filters include `aggKey`, `grouped`, `externalIdentifier`, and `recurrence`. Both lists are the references above. ## Order book Subscribe with `venueMarketOutcomeId` using [Real-time orderbook](/recipes/websocket-orderbook). See [Order books](/api/market-data#order-books) for the corresponding REST reads. ## Quote Price the trade from [Quotes](/concepts/quotes). The route id is `venueMarketOutcomeId`. ## Deep cost estimate (`deepEstimate=true`) One-time setup costs are on the quote. See [Quotes](/concepts/quotes). ## Related > **[Comparing venue prices](/recipes/comparing-venue-prices)** > > The midpoint walk across matched markets. > **[Real-time orderbook](/recipes/websocket-orderbook)** > > Live depth for the outcome ids you subscribed. > **[Market-data API](/api/market-data)** > > Bootstrap, charts, search mode, and which book id to pass. # Claim winnings Source: https://docs.agg.market/recipes/claim-winnings Find settled positions that need a claim, call POST /execution/redeem, and track the result Some venues pay a settled position on their own. Others need a claim. `POST /execution/redeem` claims the user's managed positions as USDC. It needs no wallet prompt. ## Automatic or manual, by venue | Venue | Claim | | - | - | | Polymarket, Limitless, Predict, Myriad | Manual. Call `POST /execution/redeem`. | | Kalshi, Hyperliquid, BetDEX, TipRun, PRED | Automatic. No call. | | ProphetX direct accounts | Automatic, in the user's own ProphetX account. | | Novig, ProphetX clearinghouse | No claim through AGG. The call returns `ineligible`. | Self-custody positions on Polymarket, Limitless, Predict and Myriad are redeemed with `redeemSelfCustody` (see [Self-custody](/recipes/self-custody)). This 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. On other venues the user claims them on the venue. You do not need this table at run time. Read `redeemStatus` on each position instead. ## 1. Find claimable positions `GET /execution/positions` returns one group per market. Claim the groups whose `redeemStatus` is `eligible`, using the winning outcome ids in `venueBreakdown`. ```ts SDK theme={null} const positions = await client.getExecutionPositions(); const outcomeIds = positions.data .filter((p) => p.redeemStatus === "eligible") .flatMap((p) => p.venueMarket.venueMarketOutcomes) .filter((o) => o.winner === true) .flatMap((o) => o.venueBreakdown.map((leg) => leg.venueMarketOutcomeId)); ``` ```bash cURL theme={null} curl -s https://api.agg.market/execution/positions \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ | jq '[.data[] | select(.redeemStatus == "eligible") | .venueMarket.venueMarketOutcomes[] | select(.winner == true) | .venueBreakdown[].venueMarketOutcomeId]' ``` [Get user positions](/api-reference/portfolio/get-user-positions) | `redeemStatus` | Meaning | | - | - | | `eligible` | Claim it. | | `pending` | A claim is in progress. Wait. | | `redeemed` | Paid. Nothing to do. | | `ineligible` | Nothing to claim through AGG: not settled yet, lost, settled by the venue, or self-custody. | A split or void can mark more than one outcome as a winner. Read [Market resolution & voids](/concepts/market-resolution) before you show an amount. ## 2. Redeem ```ts SDK theme={null} if (outcomeIds.length > 0) { const { redeemId, results } = await client.redeem({ venueMarketOutcomeIds: outcomeIds }); for (const r of results) console.log(redeemId, r.venueMarketOutcomeId, r.status, r.reason); } ``` ```bash cURL theme={null} curl -s -X POST https://api.agg.market/execution/redeem \ -H "x-app-id: $AGG_APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "venueMarketOutcomeIds": ["OUTCOME_ID"] }' ``` [Redeem winnings for resolved positions](/api-reference/trading/redeem-winnings-for-resolved-positions) Send `x-app-id` and the user's access token. `mode` (`live` or `paper`) is optional and defaults to live. Live claims are refused with `403` where trading is not available in the user's region. ## 3. Read the result The call returns before an on-chain claim finishes. Each `results[]` entry has a `status` and a `reason`: | `status` | Meaning | What to do | | - | - | - | | `submitted` | The claim is on its way. | Wait for completion (below). | | `confirmed` | Done, or nothing was owed. | Nothing. | | `ineligible` | Not claimable now. `reason` says why, for example `market not resolved`, `market winner not yet available`, `already redeemed`, or `venue ... does not support redeem`. | Retry later only for the first two. | | `rejected` | Refused. `reason` says why, for example `not owned by user or unknown id`, `redeem in progress`, or `position reserved for execution`. | Fix the id, or retry after the other claim or sell finishes. | To see completion, read positions again until `redeemStatus` is `redeemed`, or listen for `redeem_event` on the WebSocket. ## Related * [Market resolution & voids](/concepts/market-resolution) * [Portfolio](/recipes/portfolio) * [Execution](/concepts/execution) # Comparing Venue Prices Source: https://docs.agg.market/recipes/comparing-venue-prices Fetch matched events, walk matched markets, and read midpoints. The client batches 75 ids. The same prediction market often trades on more than one venue at the same time, at different prices. AGG's matching pipeline links those markets together as `matchedVenueMarkets` so you can build a cross-venue price comparison in three calls: 1. **List matched events** — events confirmed on two or more venues. 2. **Walk every market and its matched siblings** — collect each `venueMarketId`. 3. **Batch-fetch midpoints** — one `getMidpoints` call returns the live Yes-side price per market. The client batches 75 ids. The midpoint is the live orderbook's headline mark on a 0–1 scale, where `0.55` means the market implies a 55% probability of "Yes". Comparing midpoints across venues for the same matched event surfaces the spread an arbitrageur, smart router, or "best price" badge cares about. ## 1. Create the client ```typescript theme={null} import { createAggClient, MarketStatus, MatchStatus } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); ``` ## 2. List matched events `getVenueEvents` accepts `matchStatus` as an array. Pass `["matched", "verified"]` to restrict the response to events confirmed across more than one venue — every market in the response includes its `matchedVenueMarkets`. Calls [`GET /venue-events`](/api-reference/markets/list-venue-events). ```typescript theme={null} const { data: events } = await client.getVenueEvents({ status: [MarketStatus.open], matchStatus: [MatchStatus.matched, MatchStatus.verified], limit: 50, }); ``` ## 3. Collect every venueMarketId Walk the response and gather every market ID — the event's own markets plus each `matchedVenueMarkets[].id`. ```typescript theme={null} const ids = new Set(); for (const event of events) { for (const market of event.venueMarkets ?? []) { ids.add(market.id); for (const matched of market.matchedVenueMarkets ?? []) { ids.add(matched.id); } } } ``` The `venueMarkets` array on a list event is a preview of up to three markets. Page [`GET /venue-markets?venueEventId=`](/api-reference/markets/list-venue-markets) for the full set. Sibling venues stay on `matchedVenueMarkets[]`. Which id to pass is [Ids](/concepts/ids). Midpoints take `venueMarketId`. ## 4. Batch-fetch midpoints The client sends at most 75 ids per request and merges the responses. Pass `bestPrice=true` to add `bestBid`, `bestAsk`, and `arbReturn`. Two Hyperliquid listings in one cluster are different books. See [Hyperliquid deployers](/recipes/hyperliquid-deployers). ```ts SDK theme={null} const { data: midpoints } = await client.getMidpoints({ venueMarketIds: [...ids], bestPrice: true, }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/midpoints" \ -H "x-app-id: YOUR_APP_ID" \ --data-urlencode "venueMarketIds=vm_1" \ --data-urlencode "venueMarketIds=vm_2" \ --data-urlencode "bestPrice=true" ``` [Get live midpoints for multiple markets](/api-reference/market-data/get-live-midpoints-for-multiple-markets). ### Entries are never dropped — check `error` before trusting a price Every requested id gets an entry back, even one the API can't price. Unknown or resolved markets return `midpoint: null` alongside a structured `error` rather than being omitted, so the response array stays aligned with the ids you sent and a batched request can't silently shift. `GET /orderbooks` behaves the same way. | `error.code` | Meaning | | - | - | | `market_not_found` | The id doesn't resolve to a known market — check it | | `market_resolved` | Market has settled; prices are nulled instead of served stale | ```typescript theme={null} for (const m of midpoints) { if (m.error) { console.warn(`skipping ${m.venueMarketId}: ${m.error.code}`); continue; } // m.midpoint may still be null when the book is empty but the market is live. } ``` > [!WARNING] > > Don't treat `data[0]` as "the match" for a single-id lookup. Filter on > `venueMarketId`, and check `error` — otherwise a typo'd id yields an entry with > no price that reads like a real, cheap market. ## 5. Build the comparison Index midpoints by `venueMarketId`, then for each market look up its own midpoint plus its siblings'. ```typescript theme={null} const byId = new Map(midpoints.map((m) => [m.venueMarketId, m])); for (const event of events) { for (const market of event.venueMarkets ?? []) { const own = byId.get(market.id); const siblings = (market.matchedVenueMarkets ?? []) .map((m) => byId.get(m.id)) .filter((m): m is NonNullable => !!m); const usable = [own, ...siblings].filter( (m): m is NonNullable => !!m && m.midpoint !== null, ); if (usable.length < 2) continue; const cheapest = usable.reduce((a, b) => (a.midpoint! < b.midpoint! ? a : b)); const richest = usable.reduce((a, b) => (a.midpoint! > b.midpoint! ? a : b)); console.log(event.id, { spread: richest.midpoint! - cheapest.midpoint!, cheapest: { venue: cheapest.venue, midpoint: cheapest.midpoint }, richest: { venue: richest.venue, midpoint: richest.midpoint }, }); } } ``` ## Live updates `/midpoints` is a snapshot. There is no midpoint WebSocket channel. Live depth is the [orderbook socket](/recipes/websocket-orderbook), keyed by `venueMarketOutcomeId`. Snapshots and deltas on that socket include a midpoint for the outcome. ## Related > **[Building Market Views](/recipes/building-market-views)** > > Compose discovery and orderbook endpoints into event grids and trading views. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Live depth for an outcome id. # Correlated Markets Source: https://docs.agg.market/recipes/correlated-markets Find markets connected to another market by generated real-world outcome signals Correlated Markets starts from one prediction market and returns events that may become more relevant if that market resolves a certain way. Use it for related-market rails, portfolio ideas, hedge discovery, and thematic search that goes beyond title matching. All endpoints are app-tier: send `x-app-id`, no user token. Your app's venue and category exclusions still apply. Closed and resolved markets are left out unless you pass `includeResolved: true`. Free-text matching (`q` on resolve, and the title fallback of a text query) always searches open markets only; resolve a closed or resolved market by `venue` + `externalId`, `slug` or `conditionId` instead. ## Endpoint map | Endpoint | Use when | SDK method | | - | - | - | | [`POST /correlated-markets/resolve`](/api-reference/correlated-markets/resolve-a-market-by-venue-id-slug-or-text-and-return-cascade) | You have a venue id, external id, slug, condition id, or text and want candidates in one call. | `resolveCorrelatedMarkets` | | [`GET /correlated-markets/cascade/{venueMarketId}`](/api-reference/correlated-markets/get-ripple-effect-of-a-market-resolving-by-market-id) | You already have an AGG `venueMarketId` and want expand or hedge candidates. | `getCorrelatedMarketCascade` | | [`GET /correlated-markets/connections/{venueMarketId}`](/api-reference/correlated-markets/find-events-connected-via-shared-market-signals) | You want related events for a market, without choosing a side or mode. | none, use REST | | [`GET /correlated-markets/{venueMarketId}`](/api-reference/correlated-markets/get-correlated-market-signals-for-a-market) | You want the generated Yes/No signals for a market. | `getMarketCorrelatedSignals` | | [`POST /correlated-markets/query`](/api-reference/correlated-markets/find-correlated-markets-by-semantic-similarity) | You want free-text search across generated signals. | `queryCorrelatedMarkets` | ## Expand vs hedge * `mode: "expand"` returns Yes-side events that become more attractive if your thesis is right. * `mode: "hedge"` returns Yes-side events that become more attractive if your thesis is wrong. Hedge results leave out the expand results for the same market and side. ## Resolve a market Use `resolve` when your app stores venue-native identifiers. It resolves the market, checks whether signals exist, and returns a paginated cascade. ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id" }); const result = await client.resolveCorrelatedMarkets({ venue: "polymarket", externalId: "123456", side: "yes", mode: "expand", limit: 10, }); if ("status" in result && result.status === "processing") { // Signals are still being generated. Send the same request again later. } else { console.table(result.data); } ``` ```bash cURL theme={null} curl -X POST "https://api.agg.market/correlated-markets/resolve" \ -H "x-app-id: YOUR_APP_ID" \ -H "content-type: application/json" \ --data '{ "venue": "polymarket", "externalId": "123456", "side": "yes", "mode": "expand", "limit": 10 }' ``` `externalId` on resolve is not `externalIdentifier` and not the webhook partner id. `202` means still generating (`{ status: "processing" }`). If signals do not exist yet, generation starts and the response says so: ```json theme={null} { "market": { "id": "vm_abc123", "question": "Will oil close above $100 this year?" }, "status": "processing", "message": "Correlated market generation queued" } ``` Send the same request again after generation finishes. ## Use an AGG market id directly If you already have a `venueMarket.id` from `GET /venue-events` or `GET /venue-markets`, call the cascade endpoint. This path does not resolve identifiers. ```typescript theme={null} const cascade = await client.getCorrelatedMarketCascade({ venueMarketId: "vm_abc123", side: "yes", mode: "hedge", limit: 20, }); if (cascade.nextCursor) { const nextPage = await client.getCorrelatedMarketCascade({ venueMarketId: "vm_abc123", side: "yes", mode: "hedge", limit: 20, cursor: cascade.nextCursor, }); } ``` `limit` is capped at 100. Page with the returned `nextCursor`. ## Result shape Each result is an event-level candidate. Use `venueEventId` to fetch the event or link to it. ```json theme={null} { "venueEventId": "ve_oil_supply", "eventTitle": "OPEC production cut", "eventStatus": "open", "eventStartDate": "2026-06-18T00:00:00.000Z", "eventEndDate": "2026-07-01T00:00:00.000Z", "score": 0.72, "action": "BUY YES", "reason": "Energy markets tighten as supply risk rises", "reasonDomain": "energy" } ``` ## Inspect generated signals Each market has a set of generated Yes/No signals: short statements about which real-world states become more or less likely if the market resolves a given way. They drive the cascade and connection endpoints. Fetch them to render "why this is related" copy. ```typescript theme={null} const batch = await client.getMarketCorrelatedSignals("vm_abc123"); for (const signal of batch.signals) { console.log(signal.side, signal.direction, signal.domain, signal.text); } ``` ## Connections without side or mode `connections` returns related events for a market without a `side` or `mode`. Use it for a single "Related markets" rail on an event page. ```bash theme={null} curl -H "x-app-id: YOUR_APP_ID" \ "https://api.agg.market/correlated-markets/connections/vm_abc123?limit=10" ``` Page with `nextCursor`. Pass `includeResolved=true` to include resolved and closed markets. ## Search by theme Use query search when the user starts from a theme instead of a market. Results carry market and event status and dates, so you can keep resolved markets out of tradeable recommendations. Set `direction` to `more_likely` or `less_likely` to filter, or `balanced: true` to mix both in one page. ```typescript theme={null} const { results } = await client.queryCorrelatedMarkets({ text: "oil supply shock raises inflation risk", limit: 10, balanced: true, }); for (const match of results) { console.log(match.marketQuestion, match.marketStatus, match.eventEndDate, match.score); console.log(match.matchSource, match.matchedSignal.direction, match.matchedSignal.text); } ``` ## Related > **[Building Market Views](/recipes/building-market-views)** > > Compose discovery and orderbook endpoints into event grids and trading views. > **[Comparing Venue Prices](/recipes/comparing-venue-prices)** > > Compare matched markets across venues with live midpoint data. # Direct Order Execution Source: https://docs.agg.market/recipes/direct-order-execution Place one market order on a venue you name. externalId is required, and retrying a timeout with the same value is safe. Start from [Choose your integration](/integration-options). Use this when your backend already knows the venue and the amount. `POST /execution/orders` places one market order on that venue. It does not quote first, and it does not split across venues. To show a price before committing, quote and fill instead. Authenticate with `x-app-id` and `Authorization: Bearer`. ```ts SDK theme={null} import { Venue } from "@agg-build/sdk"; // Buy. A sell is a separate body: sellShares, and no maxSpend. await client.placeOrder({ venue: Venue.polymarket, venueMarketOutcomeId, // id from venueMarketOutcomes[].id side: "buy", externalId: "trade-0001", maxSpend: 25, }); await client.placeOrder({ venue: Venue.polymarket, venueMarketOutcomeId, side: "sell", externalId: "trade-0002", sellShares: 10, }); ``` ```bash cURL theme={null} # Buy. A sell sends sellShares and omits maxSpend. Do not send both. curl -s -X POST "$API/execution/orders" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"venue":"polymarket","venueMarketOutcomeId":"'"$OUTCOME_ID"'","side":"buy","externalId":"trade-0001","maxSpend":25}' curl -s -X POST "$API/execution/orders" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"venue":"polymarket","venueMarketOutcomeId":"'"$OUTCOME_ID"'","side":"sell","externalId":"trade-0002","sellShares":10}' ``` [Place an order directly](/api-reference/trading/place-an-order-directly). The call returns `pending`. Statuses after that are on [Order lifecycle](/concepts/order-lifecycle). `maxSpend` is the most a buy can be charged, fees included. `sellShares` bounds how many contracts a sell closes, not the proceeds. Set `slipCapBps` on a sell when the default 5% cap is too wide. ## externalId `externalId` is required and unique within the app. Two users of the same app cannot share one. Retrying a timeout with the same value is safe: the repeat does not place a second trade. A request rejected before its order was queued, such as on a balance check, does not use up the value, so you can fix the cause and resend with the same `externalId`. Once the call returns `pending` the value stays used, even if the order later fails. `GET /execution/orders?externalId=` also lists the rejected attempt's order, with status `failed`. The idempotency table, including the `409` and how to find the order after a timeout, is on [Errors](/concepts/errors#idempotency-and-safe-retries). ## Naming the outcome Pass the id from `venueMarketOutcomes[].id` for the `venue` you named. That is the form that pins the outcome. An id from another venue is resolved through matched outcomes instead of rejected, so a mismatched id can still place an order on the outcome the match selected. If several outcomes on the requested venue match and none is the one you named, the request fails with `400` and `code: "outcome_ambiguous"`. On a sell, that resolution runs over the positions held on the venue, not over every outcome. Read positions first when it matters which one closes. ## Fee overrides `appFeeBips`, `appFeeBipsByVenue`, `referrer`, `referrerFeeBips`, and `referrerFeeBipsByVenue` override the fee for one order. They need `x-app-api-key`. For a `...ByVenue` map, the entry for `venue` applies. See [Fees](/concepts/fees#app-fee). To have a wallet of yours pay Hyperliquid's activation fee for an unactivated referrer, send `referralActivationSponsorAddress`. See [Fees](/concepts/fees#where-a-referral-cannot-be-paid). ## Skipping the quote `skipQuote: true` skips the route solver and sends the order straight to the venue you named. * Venues: `predict`, `hyperliquid`, `novig`, and `prophetx` only. Any other venue returns `400`. An outcome id from another venue is resolved through matched outcomes, as without `skipQuote`. * Requires a `read_write` `x-app-api-key`. `401` without a key, `403` with a `read` key. Do not put that key in a browser. * `price` is optional. It is a 0–1 decimal such as `0.52`, not a 6-decimal integer. If you omit it, submission uses the best ask on a buy or the best bid on a sell, and an empty side returns `400`. A Hyperliquid buy is sized as the whole contracts that fit `maxSpend` at `price` plus slippage. * `prophetx` ignores `price`. The venue prices the order within `slipCapBps`. The user needs a KYC-verified wallet, or the call returns `400`. See [KYC](/recipes/kyc). * `novig` and `prophetx` settle in USDC on Solana, so a buy needs that USDC already there. * Referral fields apply to a buy as they do without `skipQuote`. On a sell they return `400` `referral_invalid`: `skipQuote` sells carry no fees. * No bridging. Funds must already sit on the venue's own chain, in its own token. `allowedSourceChainIds` and `chainBalances` are ignored. A buy the user's stored balance cannot fund returns `400` `quote_insufficient_balance` before the order is accepted, so its `externalId` stays free. * `skipBalance: true` together with `skipQuote` skips the balance check and the fund hold on a buy. Nothing checks the balance, and an underfunded order fails at the venue. `skipBalance: true` without `skipQuote` keeps the route and skips only the quote-time balance refresh, so the route uses the last stored balances. The fund hold and the balance check still run. A stale balance can fail the order after it was accepted, and it cannot overspend. Either form requires a `read_write` key. * Sells are always position-checked. `skipBalance` does not apply to them. * You own the price and the minimum checks. An order the venue refuses fails after it was accepted, and that `externalId` is used up. Retry with a new one. `maxSpend` stays the buy ceiling, and the app fee still applies. ### Reconciling filled orders `executionPrice` on `GET /execution/orders` is the effective filled USD per received/sold share: `filledAmountRaw / actualSharesRaw`. Both inputs use 6-decimal atomic units, so their scales cancel. The decimal price is truncated to 18 fractional digits. It is `null` when either quantity is unavailable or invalid, or when the share quantity is zero; it never substitutes the quote. Use this ratio to reconcile the reported cash and share quantities. Fees already reflected in those quantities affect the effective price, so it is not a venue's pre-fee VWAP. `quotedPriceRaw` remains the quote reference and `averageFillPriceRaw` remains the separate limit-order field. An accepted order is not a confirmed fill; reconcile the eventual order status and quantities. # Partner External ID Linking Source: https://docs.agg.market/recipes/external-id-linking Link an authenticated AGG user to your app's internal user ID with an HMAC assertion > [!NOTE] > > This flow is for partners who need a trusted mapping between an AGG `user.id` and their own > internal user ID for rewards, analytics, or reconciliation. The assertion must come from your > backend, not from the browser. The HMAC `externalId` is the durable partner user id. It is not the venue `externalIdentifier` and not the webhook partner id. See [Ids](/concepts/ids). ## How it works **1. Generate the app secret** Generate or rotate the app secret from the AGG admin dashboard, then store it in your backend secrets manager. Never expose it to the browser. The secret is hex. **2. Sign the assertion on your backend** Sign HMAC-SHA256 over the string `{externalId}:{timestamp}`. `timestamp` is an integer in milliseconds. The key is that hex secret decoded from hex, not the hex text as UTF-8. Send the hex digest as `hmac`. A timestamp more than five minutes off the server clock is rejected. **3. Send the assertion to the frontend** Return `{ externalId, timestamp, hmac }` from your own API after the user is authenticated in your app. **4. Link it in AGG** Call `client.linkExternalId(assertion)` or the `useExternalId()` hook. AGG returns the updated `UserProfile` with `externalId` populated. ## Webhook-assisted linking If your backend needs to connect new AGG users to existing partner users, subscribe to `accounts.created`. The event includes the AGG `userId` and, when available from magic-link or OAuth sign-in, `email`. Use that email to resolve your internal user record, then complete the normal signed assertion flow for that authenticated user. After `client.linkExternalId(assertion)` succeeds, AGG emits `accounts.linked` with the AGG `userId` and your `externalId`. ```ts theme={null} import { parseWebhookEvent } from "@agg-build/sdk/server"; const event = parseWebhookEvent(rawBody, req.headers, process.env.AGG_WEBHOOK_SECRET!); if (event.type === "accounts.created" && event.data.email) { const partnerUser = await users.findByEmail(event.data.email); if (partnerUser) { await pendingLinks.store({ aggUserId: event.data.userId, externalId: partnerUser.id, }); } } ``` > [!NOTE] > > `accounts.created.data.email` is `null` when AGG does not have an email for the principal, such > as wallet-only sign-in or OAuth providers that do not return email. ## Examples ### Backend signing **Node.js** ```ts theme={null} import { signExternalId } from "@agg-build/sdk/server"; // Example: partner user record from your own database/session const externalId = currentPartnerUser.id; const assertion = signExternalId(process.env.AGG_APP_SECRET!, externalId); // Return this from your backend to your frontend return assertion; ``` **Python** ```python theme={null} import hashlib import hmac import time external_id = current_partner_user["id"] timestamp = int(time.time() * 1000) payload = f"{external_id}:{timestamp}".encode() digest = hmac.new( bytes.fromhex(AGG_APP_SECRET), payload, hashlib.sha256, ).hexdigest() assertion = { "externalId": external_id, "timestamp": timestamp, "hmac": digest, } ``` ### Link the user Send the assertion with the user's access token. A `409` means that external id is already linked to another user in this app. ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); // assertion comes from your backend const profile = await client.linkExternalId(assertion); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/users/me/external-id \ -H "x-app-id: $AGG_APP_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "content-type: application/json" \ -d '{ "externalId": "partner-user-123", "timestamp": 1712345678901, "hmac": "HEX_DIGEST" }' ``` [Link partner external user ID (HMAC-verified)](/api-reference/users/link-partner-external-user-id-hmac-verified). ```tsx theme={null} import { useEffect, useRef } from "react"; import { useExternalId } from "@agg-build/hooks"; function LinkPartnerAccount({ assertion, }: { assertion: { externalId: string; timestamp: number; hmac: string } | null; }) { const { linkExternalId } = useExternalId(); const lastLinkedAssertion = useRef(null); useEffect(() => { if (!assertion) return; const key = `${assertion.externalId}:${assertion.timestamp}`; if (lastLinkedAssertion.current === key) return; lastLinkedAssertion.current = key; void linkExternalId(assertion).catch(() => { lastLinkedAssertion.current = null; }); }, [assertion, linkExternalId]); return null; } ``` Once linked, `externalId` is returned on the updated `UserProfile` and on future `client.getCurrentUser()` calls. # Hosted venue accounts Source: https://docs.agg.market/recipes/hosted-venue-accounts Provision a hosted BetDEX account, fund it with Solana USDC, and track withdrawals. AGG hosts a BetDEX account for the signed-in user. Authenticate with `x-app-id` and `Authorization: Bearer`. These endpoints currently support the venue slug `betdex`. Send `{ "mode": "live" }` as the request body. Provisioning returns the account with HTTP 200 or 202; read the account status before requesting its deposit address. ```ts SDK theme={null} const account = await client.provisionHostedVenueAccount("betdex", { mode: "live" }); ``` ```bash cURL theme={null} curl -s -X POST \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode":"live"}' \ "$API/users/me/venue-accounts/betdex" ``` Full shape on [Provision hosted venue account](/api-reference/hosted-venue-accounts/provision-hosted-venue-account). Read it back with [Get hosted venue account](/api-reference/hosted-venue-accounts/get-hosted-venue-account). ## Deposit address Deposit Solana USDC to the address returned for this hosted account. It credits the BetDEX account, separately from the managed balance described in [Funding](/concepts/funding). ```ts SDK theme={null} const deposit = await client.getHostedVenueDepositAddress("betdex"); ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/users/me/venue-accounts/betdex/deposit-address" ``` Full shape on [Get hosted venue deposit address](/api-reference/hosted-venue-accounts/get-hosted-venue-deposit-address). ## Withdraw `amountRaw` is 6-decimal USDC (`"1000000"` is 1 USDC). `requestId` is a UUID v4 and is the idempotency key. The response `decimals` is `6`. ```ts SDK theme={null} const withdrawal = await client.withdrawHostedVenue("betdex", { mode: "live", requestId, // UUID v4 amountRaw: "1000000", // 1 USDC, 6 decimals destinationAddress, }); ``` ```bash cURL theme={null} curl -s -X POST \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode":"live","requestId":"'"$REQUEST_ID"'","amountRaw":"1000000","destinationAddress":"'"$SOLANA_ADDRESS"'"}' \ "$API/users/me/venue-accounts/betdex/withdrawals" ``` Full shape on [Withdraw hosted venue funds](/api-reference/hosted-venue-accounts/withdraw-hosted-venue-funds). Poll [Get hosted venue withdrawal](/api-reference/hosted-venue-accounts/get-hosted-venue-withdrawal). [Get hosted venue account activity](/api-reference/hosted-venue-accounts/get-hosted-venue-account-activity) returns signed exact 18-decimal amounts. That scale is only on the activity ledger. # Hyperliquid Deployers Source: https://docs.agg.market/recipes/hyperliquid-deployers Why one Hyperliquid market can be listed twice, how to tell the listings apart, and which book you are actually reading Hyperliquid markets are listed by **deployers** — independent HIP-4 publishers, each identified by a short namespace (`ap`, `aq`, …). Two deployers can list the *same real-world market* independently. When they do, AGG sees two Hyperliquid listings of one question. This page covers how those listings surface, how to tell them apart, and the one place where treating them as interchangeable will give a user depth they cannot trade against. > [!NOTE] > > **Availability.** The `deployerVenue` field and cross-deployer clustering described below ship > together and stay dormant until Hyperliquid mainnet migrates to its template model. Until then > `deployerVenue` is `null` on every row of every venue, and Hyperliquid lists one deployer per > market — so the "today" behaviour in this page is what you will observe. Build against both > shapes; the transition happens without an API version change. ## What a deployer is On Hyperliquid, the venue does not publish markets — deployers do. Each deployer runs its own contracts and its own orderbook. Two deployers listing "Arsenal vs Chelsea" are publishing two distinct, **non-fungible** instruments: separate tokens, separate books, separate settlement. That is the critical property. Everywhere else in AGG, two listings of the same question live on *different venues*, and a user picks one. Here they live on the *same venue*, and the distinction is the deployer namespace. ## How duplicate listings surface AGG clusters the two Hyperliquid listings together **through a non-Hyperliquid anchor**. If the same market is also on Polymarket, both Hyperliquid listings match Polymarket, and transitively land in one cluster: ``` Matched cluster ├── venueEvent (polymarket) ← anchor ├── venueEvent (hyperliquid, deployer "ap") ← member └── venueEvent (hyperliquid, deployer "aq") ← member ``` So `GET /venue-events` returns **one row**, and `matchedVenueEvents[]` carries *two entries with `venue: "hyperliquid"`*. > [!WARNING] > > **This is the one exception to "two events from the same venue never share a cluster."** That rule > in [Matched Clusters](/recipes/matched-clusters) holds for every venue including Hyperliquid's > *companion* events — but not for Hyperliquid deployers. If your code asserts venue-uniqueness > within `matchedVenueEvents[]`, or keys a map by `venue`, it will silently drop one listing. ### Detecting it Count Hyperliquid entries rather than assuming one: ```js theme={null} const hlMembers = event.matchedVenueEvents.filter((m) => m.venue === "hyperliquid"); if (hlMembers.length > 1) { // Same question, multiple deployers. Their books are not interchangeable. } ``` The same applies one level down: a market's `matchedVenueMarkets[]` can carry more than one `venue: "hyperliquid"` sibling. ### When they do *not* cluster Cross-deployer clustering needs the non-Hyperliquid anchor. Two deployers listing a market that **no other venue carries** have no middle node, so they stay two separate clusters — two rows from `GET /venue-events`, two tiles. This is expected, not a gap. Hyperliquid-only markets are the common case for long-tail listings. If you need to collapse those in your own UI, group on `aggKey` — with [its caveats](/recipes/agg-key), chiefly that it is `null` for a substantial share of rows. ## Naming the deployer `deployerVenue` is exposed on the orderbook batch response, not on discovery rows. Request the cluster's market ids and read it off `matchedMarkets[]`: ```ts SDK theme={null} const { data } = await client.getOrderbooks({ venueMarketIds: ["vm_abc", "vm_def"] }); const deployers = data[0]?.matchedMarkets?.map((m) => m.deployerVenue); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/orderbooks" \ --data-urlencode "venueMarketIds=vm_abc,vm_def" \ -H "x-app-id: $AGG_APP_ID" ``` Full shape on [Get live orderbooks for multiple markets](/api-reference/market-data/get-live-orderbooks-for-multiple-markets). ```jsonc theme={null} { "data": [ { "venueMarketId": "vm_abc", "status": "ok", "matchedMarkets": [ { "venue": "polymarket", "venueMarketId": "vm_abc", "hasOrderbook": true, "deployerVenue": null }, { "venue": "hyperliquid", "venueMarketId": "vm_def", "hasOrderbook": true, "deployerVenue": "ap" }, { "venue": "hyperliquid", "venueMarketId": "vm_ghi", "hasOrderbook": true, "deployerVenue": "aq" } ], "venueOrderbooks": { "polymarket": { "venueMarketId": "vm_abc", "orderbook": { "bids": [], "asks": [] }, "deployerVenue": null }, "hyperliquid": { "venueMarketId": "vm_def", "orderbook": { "bids": [], "asks": [] }, "deployerVenue": "ap" } } } ] } ``` `deployerVenue` is `null` for every non-Hyperliquid venue, and `null` for Hyperliquid listings that carry no deployer namespace. ## Reading the right book Note the asymmetry in the response above: `matchedMarkets[]` has **three** entries, `venueOrderbooks` has **two**. > [!WARNING] > > **`venueOrderbooks` is keyed by bare venue name, so it holds at most one Hyperliquid book.** When a > cluster has two deployer members, exactly one member's book is published. `venueOrderbooks.hyperliquid.deployerVenue` > and `.venueMarketId` name which one. > > Never pair `venueOrderbooks.hyperliquid`'s depth with a `venueMarketId` taken from > `matchedMarkets[]`. If you place an order against deployer `aq` using depth read from deployer > `ap`'s book, the liquidity you sized against does not exist on the contract you are trading. Two fields keep this straight: | Field | Scope | What it tells you | | - | - | - | | `matchedMarkets[].hasOrderbook` | that **member** | Whether *that specific* deployer has a live book. Computed per member — it is not "is there a Hyperliquid book in the map". | | `venueOrderbooks.hyperliquid.deployerVenue` | the **published book** | Which deployer the depth in this entry belongs to. | Which member gets published is deterministic (lowest `venueMarketId`), so it does not flip between polls while cluster membership is unchanged — but treat it as an implementation detail and read `deployerVenue` rather than assuming. > [!NOTE] > > **Depth is never summed across deployers.** Two deployers' tokens are not fungible, so a combined > book would show aggregate liquidity that no single order can reach. Each entry is one deployer's > real, executable depth. ## Practical guidance * **Rendering a venue badge row** — de-duplicate by venue. Two Hyperliquid members are still "one venue" from a badge's point of view, and `venues[]` / `venueCount` on the event row already reflect that. * **Rendering a price comparison** — do *not* de-duplicate. Each deployer is a separately tradeable price; collapsing them hides a real execution choice from the user. * **Executing** — carry the specific `venueMarketId` through from quote to order. That id, not the venue name, identifies what gets traded. * **Persisting** — `(venue, externalIdentifier)` remains the durable handle, and it already distinguishes deployers. Do not build a key from `venue` alone for Hyperliquid. ## Related > **[Matched Clusters](/recipes/matched-clusters)** > > What a cluster is, which id to join on, and where each cluster field lives. > **[Comparing Venue Prices](/recipes/comparing-venue-prices)** > > Fetch matched markets and their midpoints to build a cross-venue comparison. > **[WebSocket Orderbook](/recipes/websocket-orderbook)** > > Stream live depth instead of polling the batch endpoint. > **[Canonical Market Key (aggKey)](/recipes/agg-key)** > > What aggKey is, when it is null, and the one supported way to parse it. # KYC Source: https://docs.agg.market/recipes/kyc Check shared KYC for the managed Solana wallet, then start a venue flow when a fill requires it. Use this when ProphetX or another venue returns a KYC error on fill. `GET /kyc/status` reports `{ verified }` for the managed Solana wallet. Novig and ProphetX share that status. Kalshi does not require this KYC. ProphetX's venue flow uses Veriff. This page does not document the Veriff widget. ```ts SDK theme={null} const { verified } = await client.getSharedKycStatus(); const started = await client.initiateKyc("prophetx", "https://app.example.com/kyc"); // started.status: not_applicable | not_started | pending | verified | rejected // started.kycUrl is set when the user still has to finish the venue flow ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/kyc/status" curl -s -X POST \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"redirectUri":"https://app.example.com/kyc"}' \ "$API/kyc/prophetx" ``` Full shapes on [Get shared KYC status](/api-reference/users/get-shared-kyc-status) and [Initiate KYC](/api-reference/users/initiate-kyc). # Limit Orders Source: https://docs.agg.market/recipes/limit-orders Place, poll, and cancel one venue's limit order. Self-custody cancel signatures are on the signing page. Start from [Choose your integration](/integration-options). A limit order rests on one venue at a price you set. It does not smart-route. Statuses, including which ones are final, are on [Order lifecycle](/concepts/order-lifecycle). Authenticate with `x-app-id` and `Authorization: Bearer`. Buys need spendable managed cash. Sells need shares of that outcome. AGG reserves that amount while the order is open. `limitPriceRaw` and `sizeRaw` are 6-decimal integer strings. `530000` is a price of 0.53, scaled by 1e6. `1000000` is 1 share. The price must be greater than `0` and less than `1000000` (`1000000` would be 1.00 and is rejected). `GTD` also requires a future `expiresAt`. `clientOrderId` is optional and unique per user. Reusing it returns `409`. See [Errors](/concepts/errors#idempotency-and-safe-retries). Check the market before you send. A limit order is not priced against a quote. `GET /venue-markets` has `tickSize` for every venue and `minOrderSize` only where the venue publishes a floor. A `null` `minOrderSize` means AGG has no value for that market. It does not mean there is no minimum. Kalshi's notional floor is one of those gaps. Prefer the live book tick from the orderbook, and fall back to `tickSize` when no book is loaded. A size or tick rejection is `400` and creates no order. | Venue | Slug | What AGG checks | | - | - | - | | Polymarket | `polymarket` | `sizeRaw` must be at least the market's `minOrderSize` when that field is set | | PRED | `pred` | At least 0.001 shares, aligned to 0.001. A buy must be at least \$0.90 notional | | Hyperliquid | `hyperliquid` | Whole contracts, and a `0.00001` price tick. `postOnly: true` is sent as Alo, including when `timeInForce` is `GTC` | | TipRun | `tiprun` | At least 0.01 shares and a 0.01 price tick. Managed custody only | ## Place a managed limit order ```ts SDK theme={null} import { Venue } from "@agg-build/sdk"; await client.placeLimitOrder({ venue: Venue.polymarket, venueMarketOutcomeId, // id from venueMarketOutcomes[].id side: "buy", // "sell" uses the same fields, not a different amount limitPriceRaw: "530000", // 530000 = 0.53, scaled by 1e6 sizeRaw: "1000000", // 1000000 = 1 share timeInForce: "GTC", }); ``` ```bash cURL theme={null} curl -s -X POST "$API/execution/limit-orders" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"venue":"polymarket","venueMarketOutcomeId":"'"$OUTCOME_ID"'","side":"buy","limitPriceRaw":"530000","sizeRaw":"1000000","timeInForce":"GTC"}' ``` [Place limit order](/api-reference/trading/place-limit-order). The usual first status is `pending`: accepted, not yet on the book. AGG enforces the combinations below. This is not every value of the [Venue](/api-reference/trading/place-limit-order) enum. | Venue | Slug | `timeInForce` | `postOnly` | | - | - | - | - | | Polymarket | `polymarket` | `GTC`, `GTD`, `FOK`, `FAK` | yes | | Limitless | `limitless` | `GTC`, `FOK` | yes | | Predict.fun | `predict` | `GTC`, `GTD` | no | | Hyperliquid | `hyperliquid` | `GTC`, `IOC`, `ALO` | yes | | Myriad | `myriad` | `GTC`, `GTD`, `FOK`, `FAK` | no | | PRED | `pred` | `GTC`, `GTD` | no | | Kalshi | `kalshi` | `GTC`, `GTD`, `FOK`, `IOC` | yes | | BetDEX | `betdex` | `GTC`, `IOC` | no | | ProphetX | `prophetx` | `GTC`, `FOK` | yes | | Novig | `novig` | `GTC`, `FOK` | yes | | TipRun | `tiprun` | `GTC`, `GTD`, `FOK`, `IOC` | `GTC` and `GTD` only | TipRun takes buy and sell. A `GTD` `expiresAt` must be 10 minutes to 30 days out. A `GTC` order rests for at most 22 days, then ends as venue-expired. TipRun nets opposite directions on one contract, so an order opposite to one of the user's open TipRun orders on that contract (for example buy No while buy Yes rests) is refused with `400`. Maker fills cost 0% and taker fills 1%. ProphetX and Novig accept that pair only in Direct API mode, and only on a buy. Both reject `expiresAt`. BetDEX uses hosted venue cash, not a signing wallet: `GTC` or `IOC`, no post-only, and no expiry. Odds are rounded conservatively and stake is rounded down. Unmatched `GTC` size is cancelled when the market goes in-play. A sell closes the same outcome and needs collateral as well as the shares. Execution can be unavailable. An unsupported venue, time in force, or post-only flag is rejected before the order reaches the venue. Self-custody placement is the same call with `signingAddress` set to a linked EOA, and only when that is enabled for your app. Polymarket allows `GTC` or `GTD`, buy or sell, at or above the market minimum, and the deposit wallet must already exist. The deposit wallet places the order. When enabled for your app, a legacy Polymarket Safe can move pUSD or shares into that deposit wallet first, which adds one Safe signature. A legacy proxy wallet is not supported. Hyperliquid allows `GTC` or `ALO`, buy or sell, whole contracts, and a `0.00001` price tick, with no `expiresAt`. Self-custody `IOC` is rejected. `expiresAt` is accepted only with `GTD`. `fundingAddresses` is buy-only, requires `signingAddress`, and can only fund by bridging in. On the venue's own chain, only the signing wallet pays. Omit `approveMode`, or send `approveMode: "sponsored"`, for the same EIP-7702 approval as a market fill. `approveMode: "user_broadcast"` makes the wallet send the approve. See [Choosing an approve mode](/recipes/self-custody#choosing-an-approve-mode). The response includes `quoteId` for a funded buy. Poll `getExecutionStatus({ quoteId })` and submit signatures on [Self-custody signing](/recipes/self-custody-signing). Do not call `fillSelfCustody` for that quote. Placement has already started it. An admitted `pending` order has not reached the venue. A self-custody buy does not reserve a managed balance. ## Poll a managed limit order `open` is not the only resting status, and `filled` is not the only finished one. `partially_filled_open` is resting and not terminal: some size filled, and the rest is still on the book. `partial_fill` is terminal: the unfilled rest will not fill. `cancel_pending` is not done. The loop returns on the resting pair and on a terminal status, so it does not spin on those two. ```ts theme={null} const resting = new Set(["open", "partially_filled_open"]); const terminal = new Set(["filled", "partial_fill", "cancelled", "expired", "failed"]); async function waitForLimitOrder(orderId: string) { for (;;) { const page = await client.getExecutionOrders({ orderId, limit: 1 }); const current = page.data[0]; if (!current) throw new Error("Order was not found"); if (resting.has(current.status) || terminal.has(current.status)) return current; await new Promise((resolve) => setTimeout(resolve, 1500)); } } ``` The list call is [Get user orders](/api-reference/portfolio/get-user-orders). What each status means is [Order lifecycle](/concepts/order-lifecycle). ## Cancel a managed limit order Cancel is `POST /execution/orders/cancel` with `orderId`. Queued and open orders can be cancelled. A terminal order cannot. ```ts SDK theme={null} const cancel = await client.cancelManagedOrder(orderId); ``` ```bash cURL theme={null} curl -s -X POST "$API/execution/orders/cancel" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"orderId":"'"$ORDER_ID"'"}' ``` [Cancel an order](/api-reference/trading/cancel-an-order). `cancel_pending` means the venue has not confirmed yet. Keep polling until `cancelled`, or until `filled` or `partial_fill` if a fill won the race. Do not stop on `open`. ## Self-custody cancels are on the signing page Build the Polymarket `ClobAuth` signature or Hyperliquid cancel action using [Self-custody signing](/recipes/self-custody-signing#limit-order-cancel-signatures). Pass that signature as `cancelSignature` to `cancelManagedOrder(orderId, { cancelSignature })`. An order a linked wallet placed on Polymarket or Hyperliquid outside AGG uses `cancelVenueOrder` with `venue`, `venueOrderId`, `signerAddress`, and `cancelSignature`. Both are the same cancel route. AGG cannot cancel a self-custody order without a new signature, and it does not retry one on its own. GTD expiry is enforced by the venue. AGG does not cancel self-custody GTC orders when a market closes. # Live Crypto Markets Source: https://docs.agg.market/recipes/live-crypto-markets Discover recurring crypto windows, live depth, and fresh reference prices for partner apps AGG's live crypto market API lets prediction market apps discover recurring crypto markets, inspect visible-window orderbook depth, and fetch fresh underlying reference prices through one integration. Build BTC, ETH, SOL, XRP, DOGE, HYPE, and other crypto market views without wiring every venue's market format and reference-price source separately. Use it for crypto prediction market pages, trading dashboards, agent workflows, and cross-venue price comparison. Recurring crypto markets are short-window markets such as "BTC up or down in the next 15 minutes". They usually need three separate data shapes: 1. **Market discovery** tells you which venue markets exist for an asset and window. 2. **Orderbook depth** tells you how much liquidity sits behind the displayed Yes/No odds. 3. **Reference prices** tell you the current underlying asset price used to evaluate the market. Use discovery responses to build cards, tables, and filters. Opt into orderbook depth for the visible rows where you need executable context, then poll the reference-price endpoint when you need the current BTC/USD, ETH/USD, or other underlying price. ## Endpoint map | Use case | API Reference | | - | - | | Browse all recurring crypto windows | [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets) | | Add depth for visible recurring markets | [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets) | | Poll current underlying prices | [Get live crypto reference prices](/api-reference/market-data/get-live-crypto-reference-prices) | | Browse all venue events by cadence | [List venue events](/api-reference/markets/list-venue-events) | | Fetch live venue orderbooks | [Get live orderbooks for multiple markets](/api-reference/market-data/get-live-orderbooks-for-multiple-markets) | > [!NOTE] > > Reference prices are underlying asset prices, not prediction-market prices. A BTC/USD > reference price belongs next to market odds; it should not be treated as a Yes/No midpoint. > Orderbook depth is prediction-market liquidity for an outcome, not an underlying asset price. ## What you get * Deterministic windows grouped by `asset`, `quoteAsset`, `duration`, `windowStart`, and `windowEnd`. * Venue market rows for each window, including outcomes, midpoint-style market odds, metrics, routeability flags, and reference-price metadata. * Optional per-outcome `orderbookDepth` with buy/sell levels, notional totals, average and worst sweep prices, and fill-completeness fields. * Fresh USD reference prices with `softRefreshMs`, `hardMaxAgeMs`, `ageMs`, `stale`, and target-level `error` fields so your UI can degrade cleanly. * Cross-venue comparison fields for reference prices and up/down outcome prices. ## Cadence filters AGG exposes recurrence filters as fixed enum values. There is no separate cadence catalog endpoint to call from your frontend. Venue event discovery accepts the full recurrence enum, while recurring crypto discovery currently supports the short-window crypto durations listed below. | Crypto market duration | API value | | - | - | | 5 minutes | `PT5M` | | 15 minutes | `PT15M` | | 30 minutes | `PT30M` | | 1 hour | `PT1H` | | 4 hours | `PT4H` | | Daily | `P1D` | `durations` and `interval` also accept the friendly aliases `5m`, `15m`, `30m`, `1h`, `4h`, and `1d` for those same windows. Venue-event recurrence is a wider enum and is not this alias list. Venue event discovery also accepts broader recurrence filters: | Label | API value | | - | - | | 5 minutes | `PT5M` | | 10 minutes | `PT10M` | | 15 minutes | `PT15M` | | 30 minutes | `PT30M` | | 1 hour | `PT1H` | | 4 hours | `PT4H` | | Daily | `P1D` | | Weekly | `P1W` | | Monthly | `P1M` | | Yearly | `P1Y` | | One-off events | `null` | The SDK exports the supported cadence list: ```typescript theme={null} import { RECURRENCE_CADENCES, MarketStatus } from "@agg-build/sdk"; console.log(RECURRENCE_CADENCES); // ["PT5M", "PT10M", "PT15M", "PT30M", "PT1H", "PT4H", "P1D", "P1W", "P1M", "P1Y"] ``` For `listRecurringCryptoMarkets`, use the `RecurringCryptoDuration` values `PT5M`, `PT15M`, `PT30M`, `PT1H`, `PT4H`, and `P1D`. Use these same ISO values with venue event discovery. Calls [List venue events](/api-reference/markets/list-venue-events). ```typescript theme={null} const { data: events } = await client.getVenueEvents({ status: [MarketStatus.open], recurrence: ["PT5M", "PT15M", "PT30M", "PT1H", "PT4H"], limit: 50, }); ``` > [!NOTE] > > There is no recurrence-cadence catalog endpoint. If your integration previously > expected a dynamic cadence list, read `RECURRENCE_CADENCES` from the SDK or use > the enum shown in the API reference. Use the literal `null` only when filtering > venue events with no recurring schedule. ## 1. Create the client ```typescript theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); ``` ## 2. List recurring crypto windows `listRecurringCryptoMarkets` groups venue markets by asset, quote asset, duration, and window. Use it for crypto-specific market views where the user expects "current BTC 15m", "current ETH 1h", or similar rows. ```ts SDK theme={null} const windows = await client.listRecurringCryptoMarkets({ assets: ["BTC", "ETH"], durations: ["PT15M", "PT30M", "PT1H"], window: "current", status: [MarketStatus.open], }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/crypto/recurring-markets" \ -H "x-app-id: YOUR_APP_ID" \ --data-urlencode "assets=BTC" \ --data-urlencode "assets=ETH" \ --data-urlencode "durations=PT15M" \ --data-urlencode "durations=PT30M" \ --data-urlencode "durations=PT1H" \ --data-urlencode "window=current" \ --data-urlencode "status=open" ``` [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets). Each returned window includes: * `asset`, `quoteAsset`, `duration`, `windowStart`, and `windowEnd` * `markets[]`, one item per venue market in that window * per-market `outcomes[]` with market odds such as `midpoint`, `bestBid`, and `bestAsk` * per-market `resolution`, which can be passed back when polling reference prices > [!NOTE] > > `includeDirectVenueMarkets` is deprecated and ignored. Every recurring window is listed from > AGG's own market data before it opens, so you can drop the parameter; the response is the same > with or without it. ## 3. Add depth for visible markets Use `includeOrderbookDepth` when the user is looking at a small, active set of current windows and you need liquidity context next to the displayed odds. Keep it off for broad catalog pages, then opt in for the rows that are actually on screen. Same route as above: [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets). ```typescript theme={null} const windowsWithDepth = await client.listRecurringCryptoMarkets({ assets: ["BTC", "ETH"], durations: ["PT5M", "PT15M"], window: "current", status: [MarketStatus.open], limit: 20, includeOrderbookDepth: true, orderbookDepth: 15, orderbookDepthAmountUsd: 1000, }); ``` `orderbookDepth` accepts 1 through 15 levels per side. `orderbookDepthAmountUsd` has no fixed public upper cap, but it must be positive. Use the size your UI wants to inspect or simulate. Outcomes without a live book return `orderbookDepth: null`, so keep the depth display optional. ```typescript theme={null} for (const windowMarket of windowsWithDepth.data) { for (const market of windowMarket.markets) { for (const outcome of market.outcomes) { const depth = outcome.orderbookDepth; if (!depth) continue; console.log(outcome.label, { amountUsd: depth.amountUsd, buyLevels: depth.buy.levels.length, sellLevels: depth.sell.levels.length, buyAverage: depth.buy.avgPrice, sellAverage: depth.sell.avgPrice, }); } } } ``` > [!NOTE] > > Use the recurring endpoint for grouped crypto windows. For a dedicated orderbook view or a > larger explicit set of venue markets, use the `/orderbooks` API or WebSocket orderbook stream. ## 4. Poll reference prices Use `getCryptoReferencePrices` with the windows or markets returned by discovery. The SDK will build the correct target payload for each venue market. Calls [Get live crypto reference prices](/api-reference/market-data/get-live-crypto-reference-prices). ```typescript theme={null} const prices = await client.getCryptoReferencePrices({ windowMarkets: windows.data, }); for (const row of prices.data) { if (row.error) { console.warn(row.venueMarketId, row.error.code, row.error.message); continue; } console.log(row.venueMarketId, { asset: row.asset, price: row.normalized, fetchedAt: row.fetchedAt, ageMs: row.ageMs, }); } ``` The response includes `softRefreshMs` and `hardMaxAgeMs`. Use `softRefreshMs` as your normal poll interval for active views. Treat any row with `error`, `stale: true`, or a `null` `normalized` price as unavailable for display. Chainlink-sourced rows can be up to 2 seconds old (Chainlink publishes about once a second); judge freshness by each row's `stale` flag rather than comparing `ageMs` to `hardMaxAgeMs`. ```typescript theme={null} async function pollReferencePrices() { const response = await client.getCryptoReferencePrices({ windowMarkets: windows.data }); renderReferencePrices(response.data); window.setTimeout(pollReferencePrices, response.softRefreshMs); } void pollReferencePrices(); ``` ## 5. Match prices back to markets Reference-price rows include the same venue-facing identifiers you already have from discovery. Index by `venueMarketId` for normal UI work. ```typescript theme={null} const byVenueMarketId = new Map( prices.data .filter((row) => !row.error && row.venueMarketId) .map((row) => [row.venueMarketId!, row]), ); for (const windowMarket of windows.data) { for (const market of windowMarket.markets) { const reference = byVenueMarketId.get(market.venueMarketId); console.log({ venue: market.venue, question: market.question, underlying: reference?.normalized ?? null, upMidpoint: market.outcomes.find((o) => o.label.toLowerCase() === "up")?.midpoint, downMidpoint: market.outcomes.find((o) => o.label.toLowerCase() === "down")?.midpoint, }); } } ``` ## API reference Use the generated API Reference for request parameters, response schemas, and try-it-out examples: | Use case | API Reference | | - | - | | Discover recurring crypto windows | [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets) | | Add orderbook depth to visible windows | [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets) | | Poll current underlying prices | [Get live crypto reference prices](/api-reference/market-data/get-live-crypto-reference-prices) | | Browse venue events by recurrence | [List venue events](/api-reference/markets/list-venue-events) | ## Trading behavior Recurring crypto discovery can include venue markets that are useful for display but not valid inputs to AGG smart order routing. Check these fields before enabling trade controls: * `market.routeable` * `market.splitOrdersAllowed` * `outcome.routeable` * `outcome.splitOrdersAllowed` If `routeable` is `false`, do not pass that market to smart routing. If `splitOrdersAllowed` is `false`, do not split an order across venues for that market. ## Integration notes * Reference prices are USD-only (`quoteAsset: "USD"`) and are separate from prediction-market odds. * `listRecurringCryptoMarkets` supports deterministic crypto durations: `PT5M`, `PT15M`, `PT30M`, `PT1H`, `PT4H`, and `P1D`, plus aliases `5m`, `15m`, `30m`, `1h`, `4h`, and `1d`. * `getCryptoReferencePrices` accepts up to 100 targets. For active views, pass the visible windows or venue market ids rather than polling every discovered market. * Prefer `getCryptoReferencePrices` for live reference-price polling. The discovery endpoint can include reference prices, but the dedicated polling endpoint is built for fresh active views. * Use `includeOrderbookDepth` only for visible rows that need liquidity context. Depth is capped at 15 levels per side, and `orderbookDepthAmountUsd` must be greater than 0. * Treat rows with `error`, `stale: true`, or `normalized: null` as unavailable for display. * `source.confidence` can be `verified`, `inferred`, or `missing`. Show low-confidence or missing sources conservatively in production trading experiences. ## Recommended UI flow 1. Call `listRecurringCryptoMarkets` for the current window and render the market cards. 2. Include `orderbookDepth` only for rows where the user needs liquidity or sweep context. 3. Call `getCryptoReferencePrices` for the visible windows. 4. Display market odds, orderbook depth, and reference prices as separate values. 5. Poll reference prices using `softRefreshMs` while the view is visible. 6. Stop polling when the user leaves the view or the window status is no longer `open`. ## FAQ ### Are reference prices the same as market prices? No. Reference prices are underlying crypto prices, such as BTC/USD or ETH/USD. Market prices are the prediction market odds for outcomes such as Up or Down. Orderbook depth is liquidity behind those outcome prices. ### Which crypto windows are supported? The recurring crypto endpoint supports `PT5M`, `PT15M`, `PT30M`, `PT1H`, `PT4H`, and `P1D`, and the aliases `5m`, `15m`, `30m`, `1h`, `4h`, and `1d`. Venue event discovery can still filter broader recurrence cadences, but this crypto-specific endpoint is intentionally scoped to deterministic recurring crypto windows. ### What should I do when a reference price is stale or unavailable? Do not render it as a current price. Hide it, show an unavailable state, or keep the last accepted value visually distinct from live data. Always read `error`, `stale`, `ageMs`, and `normalized` before displaying a reference price. ## Related > **[aggKey](/recipes/agg-key)** > > Optional fetch filter when a crypto window key converges. > **[Ids](/concepts/ids)** > > Which id to store. aggKey is not that id. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Stream live orderbook changes for tradable venue markets. # Managed Balance Refills Source: https://docs.agg.market/recipes/managed-balance-refills Keep a user's managed USDC balance above a target-chain minimum after trading > [!NOTE] > > Balance refill endpoints require a signed-in user session. Start with > [Authentication](/recipes/authentication) and [Funding & Withdrawals](/concepts/funding). Managed balance refills let a user keep a USDC buffer on a selected chain. The user chooses a minimum balance and a refill amount, and AGG can move USDC from another one of the user's managed chains after trading lowers the target-chain balance. For example, a user can set a `$100` minimum and a `$50` refill amount on Polygon. After a completed trade: | Observed Polygon balance | Result | | - | - | | `$120` | No refill is needed. | | `$80` | AGG requests at least `$50`, bringing the balance to about `$130`. | | `$20` | AGG requests at least `$80`, enough to restore the `$100` minimum. | The requested delivery is the larger of the configured refill amount or the amount needed to restore the minimum. Network and bridge fees are paid from the source balance and must stay within the user's configured maximum fee. ## When a refill runs A refill is considered only after a trade has finished and all orders linked to that trade are in a terminal state, such as filled, cancelled, expired, or failed. AGG refreshes the balance after the trade before comparing it with the policy minimum. See [Funding](/concepts/funding). A deposit or a withdrawal does not start a refill. > [!NOTE] > > Creating a policy or making a withdrawal does not immediately trigger a refill. An open resting > limit order does not trigger one either. Trading always takes priority: * If another trade is active or queued, AGG waits until queued trading is finished before considering the refill. * Once a refill has started, a newly submitted trade waits for the refill to finish. * AGG runs at most one balance-moving operation for a user at a time. ## Create a policy `minimumRaw` and `refillAmountRaw` are 6-decimal USD integer strings. `1000000` is \$1. Optional: `targetTokenSymbol` `"USDC"`, `dailyCapRaw`, and `maxFeeRaw`. A policy is `enabled` or `paused`. It is not an order, so it is never `cancelled`. ```ts SDK theme={null} // Call after the user authenticates. await client.createBalanceRefillPolicy({ targetChainId: 137, minimumRaw: "100000000", // 1000000 = $1, so this is $100 refillAmountRaw: "50000000", // $50 }); ``` ```bash cURL theme={null} curl -s -X POST "$API/execution/balance-refill-policies" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"targetChainId":137,"minimumRaw":"100000000","refillAmountRaw":"50000000"}' ``` [Create a managed balance refill policy](/api-reference/funding/create-a-managed-balance-refill-policy). The target chain must support managed USDC. A user can have one policy per target chain and can create policies for more than one chain. By default, refills are limited to `$1,002` per UTC day (up to `$1,000` of refills plus fees) and `$2` in fees per refill. Pass `dailyCapRaw` or `maxFeeRaw` when the user wants different limits. The daily cap is applied conservatively to each requested delivery plus its maximum allowed fee and must be large enough to cover the configured amounts and fee limit. See the [Create balance refill policy API](/api-reference/funding/create-a-managed-balance-refill-policy) for field constraints and the complete request and response schemas. ## Read, update, or pause a policy `getBalanceRefillPolicies` lists them. `updateBalanceRefillPolicy` changes `minimumRaw`, `refillAmountRaw`, `dailyCapRaw`, or `maxFeeRaw`, and sets `status` to `enabled` or `paused`. After a transfer completes or fails, AGG applies the policy's five-minute cooldown before another attempt can start. The policy response includes `lastObservedRaw`, `lastEvaluatedAt`, `nextEligibleAt`, and `latestAttempt` so your app can show its current state without reproducing the refill logic. > **[List policies](/api-reference/funding/list-managed-balance-refill-policies)** > > Read the user's configured policies and latest attempt. > **[Update or pause a policy](/api-reference/funding/update-or-pause-a-managed-balance-refill-policy)** > > Change limits or switch a policy between enabled and paused. ## Monitor refill attempts `getBalanceRefillAttempts(policyId)` returns the recent attempts. An attempt moves through `reserved` and `executing`, then ends as `completed` or `failed`. Use `completedAmountRaw`, `feeRaw`, and `errorMessage` to present the final outcome. The endpoint returns the 25 most recent attempts for the policy. > **[List refill attempts](/api-reference/funding/list-recent-managed-balance-refill-attempts)** > > View the complete attempt response schema and status fields. ## Source balances and fees AGG selects an available managed USDC balance on a different supported chain. The source must have enough spendable USDC for the requested delivery and the maximum fee; funds already reserved for orders or other operations are not available to a refill. Managed balance refills do not pull money from a bank account, card, or external wallet. If no eligible managed source balance is available, or if the fee or daily cap would be exceeded, no transfer starts. The policy remains enabled and can be considered again after a later completed trade. A refill and a withdrawal cannot spend the same managed balance at the same time. If a refill is already in progress, a withdrawal returns `409`. The withdrawal itself does not start a refill. The next eligible completed trade can. Deposits and withdrawals are on [Funding](/concepts/funding). ## API reference > **[Create a policy](/api-reference/funding/create-a-managed-balance-refill-policy)** > > Configure a target chain, balance minimum, and refill amount, with optional custom safety limits. > **[List policies](/api-reference/funding/list-managed-balance-refill-policies)** > > Load all policies owned by the authenticated user. > **[Update a policy](/api-reference/funding/update-or-pause-a-managed-balance-refill-policy)** > > Change thresholds or pause and resume automatic refills. > **[List attempts](/api-reference/funding/list-recent-managed-balance-refill-attempts)** > > Review recent refill activity and outcomes. # Market maker trading Source: https://docs.agg.market/recipes/market-maker Let a market maker algo-trade on your app with their own wallets, without access to your users > [!NOTE] > > **You'll need:** a `trade` API key for your app, your app id, fee recipients configured, and a > non-zero app fee with self-custody fee collection enabled. > **Result:** orders, webhooks, and the app fee attributed to your app. The market maker signs > with their own wallet. You do not create their user first. A builder code attributes venue volume. It does not place the trade on your Agg app, so it does not produce your Agg orders, order webhooks, or app fee. A trader key does. Give the market maker the key, the app id, and this page. They sign in as many wallets as they want, whenever they want. The first successful sign-in creates the user on your app. Later sign-ins mint a session for that same user. The user belongs to the app and the wallet, not to a key. Someone who signed in with a `read` or `read_write` key, or with no key, is the same user when they sign in with the trader key. Someone who first signed in with the trader key is the same user when they sign in with another of your keys. The token from either sign-in works with the other key. The key does not replace the user token and cannot act as a user who has not signed in. The key is trading access for your app only. It cannot list or read your other users, manage keys, members, settings, webhooks, or security, or set a per-trade fee or referral. It works only with your app id. `skipQuote` and `skipBalance` stay on `read_write` keys. Direct orders (`POST /execution/orders`) reject `signingAddress`, so this flow uses quote, then fill. Venue trader points stay on the market maker's own venue account. Agg does not move those points to you. What you receive is the Agg order on your app, the order webhook, your app fee collected to `feeRecipientEvm` or `feeRecipientHypercore`, and your [builder code](/recipes/builder-codes) when one is configured. Fee collection on this path works for a buy funded from one EVM wallet, with `approveMode` omitted or `sponsored`. Other shapes can skip the fee. See [Self-custody trading](/recipes/self-custody). > [!WARNING] > > New apps stay in [testing mode](/environments#testing-mode) until the partner agreement is > signed: 20 users and 100 trades. A trader key does not raise those caps. ## Create the key In the admin dashboard, open **API** and create a key with the **Trader** permission. The secret is shown once. You can have three active trader keys. They do not count toward the five read and read/write keys. Revoking a key stops it on the next call and does not delete users it already created. To freeze one wallet, suspend that user. ## Rate limits Send the trader key on every call, including sign-in, quotes, and fills. The key has its own copy of your app's server limits: 18,000 requests per minute on `agg:rl:apikey:`, plus the per-endpoint API-key ceiling (quotes 1,500 per minute, trading 6,000 per minute) on `agg:rl:route:v1::hash(key:)`. Those calls do not count toward your app's user buckets. A user token sent without the key is a normal user call. It uses the lower per-user ceilings and counts toward `agg:rl:app::user:`. There is no single shared `agg:rl:app:` bucket. ## Trade The market maker keeps custody of the key. Agg never holds it. The signing wallet is the one that signed in, so there is no separate step to link it. **1. Sign in** `POST /auth/start` with `provider: "siwe"`, `x-app-id`, and `x-app-api-key`. A valid trader key skips the per-IP sign-in limit and Turnstile. The key's own rate limit still applies. Sign the nonce with the market maker's EOA and `POST /auth/verify`. Keep the user access token. **2. Quote** `GET /orderbook/{venueMarketOutcomeId}/route` with the user token, the trader key, and `signingAddress` set to that EOA. Omit `appFeeBips` and referral fields. The app's configured fee applies. A different address is refused. **3. Fill and sign** `POST /execution/fill` with the same `signingAddress`, then poll `GET /execution/status?quoteId=` and submit signatures. The sequence is the same as [self-custody signing](/recipes/self-custody-signing). Limit orders and cancel use the same session. Cancel still needs `cancelSignature`. Kalshi still needs the user's own venue key from [Venue keys](/recipes/venue-keys). The secret is never returned. `GET /apps/{appId}/users` and `GET /apps/{appId}/orders` return `403` for this key. A `read` key still has partner admin. Using the trader key with another app's `x-app-id` returns `403`. # Matched Clusters Source: https://docs.agg.market/recipes/matched-clusters Where matched-cluster fields live on the event, market, and outcome. The same real-world question is listed separately on every venue. Discovery returns those listings as one matched cluster. Which id to store is [Ids](/concepts/ids). ## What a cluster is A cluster is a set of venue events AGG has decided are the same real-world event, plus the market and outcome pairings between them. ``` Matched cluster ├── venueEvent (polymarket) ← anchor: the row an unfiltered list returns ├── venueEvent (kalshi) ← member ├── venueEvent (predict) ← member └── venueEvent (limitless) ← member Each venueEvent ├── matchedVenueEvents[] ← the same event on the other venues └── venueMarkets[] ├── venueMarketOutcomes[] (Yes / No) │ └── matchedVenueMarketOutcomes[] ← the aligned outcome elsewhere └── matchedVenueMarkets[] ← the same market on the other venues ``` Each level is symmetric: you get the whole set no matter which member you started from. You consume the cluster on the response. You do not rebuild it. ## Cluster fields | Field | Scope | What it tells you | | - | - | - | | `venues[]`, `venueCount` | the event row | Which venues the cluster spans. Use it for venue badges. | | `matchedVenueEvents[]` | the event row | The same event on the other venues: `id`, `venue`, `externalIdentifier`, `title`, `slug`, `status`, and `series.externalIdentifier` (needed for a canonical Kalshi URL). Empty when the event is in no cluster. | | `marketCount`, `groupMarketCount` | the event row | How many markets that row carries. See [Market lists](#market-lists-preview-vs-full) for which set `marketCount` counts. | | `groupParentId` | the event row | Set when the row is a same-venue companion. `null` on the group base or a standalone event. See [Companion events](#companion-events-why-a-cluster-can-look-empty). | | `matchedVenueMarkets[]` | each market | The same market on the other venues, with their outcomes. | | `matchedVenueMarketOutcomes[]` | each outcome | `{ venueMarketId, venueMarketOutcomeId }` for the aligned outcome on each matched venue. | | `settlementDiff` | the cluster, on event detail | Resolution-criteria differences. See [Settlement differences](/recipes/settlement-key-differences). | `venues[]` is how many venues. `matchedVenueEvents[]` is which events. Do not enumerate members by walking the list preview of `venueMarkets[]`. That preview is up to three markets, and a member with no matched market never appears there. Cross-venue prices hang off `matchedVenueMarkets[]`. The walk is [Comparing prices](/recipes/comparing-venue-prices). Live depth is the [orderbook socket](/recipes/websocket-orderbook), which takes an outcome id. ## Look up by external identifier Filtering `venue` plus `externalIdentifier` returns that venue's row, not the cluster anchor. The same cluster is reachable from any member. `GET /venue-events` accepts the same pair. [List venue events](/api-reference/markets/list-venue-events). `getVenueEvents` does not send `externalIdentifier`. The SDK call for this lookup is `getVenueMarkets`. ```ts SDK theme={null} import { createAggClient, Venue } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const { data } = await client.getVenueMarkets({ venue: Venue.polymarket, externalIdentifier: ["3471056"], }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/venue-markets" \ --data-urlencode "venue=polymarket" \ --data-urlencode "externalIdentifier=3471056" \ -H "x-app-id: YOUR_APP_ID" ``` [List venue markets](/api-reference/markets/list-venue-markets). `matchedVenueMarkets[]` is the same market on the other venues. A cluster can contain two markets from the same venue. Kalshi lists a head-to-head as one market per team, and both can be matched, so two Kalshi rows in one cluster are not a duplicate. `GET /venue-market-outcomes` takes `externalIdentifier`. `venue` is optional there and only narrows. There is no dedicated SDK method. `client.request` can call it. [Look up venue market outcomes by external identifier](/api-reference/markets/look-up-venue-market-outcomes-by-external-identifier). The page returns id pairs on `matchedVenueMarketOutcomes[]` and identity fields on page-level `matchedVenueMarkets[]`. Rules text is not on that response. * `venue` is required on `/venue-events` and `/venue-markets`. Omitting it returns 400. * Repeat `externalIdentifier` for up to 20 values. More than 20 returns 400. * Unknown identifiers are omitted, not returned as nulls. Match rows back by `externalIdentifier`. * Kalshi puts its ticker on the Yes outcome only. The No outcome has no identifier. Fetch the market and read `venueMarketOutcomes[]`. An empty `matchedVenueEvents[]` can be a companion rather than a miss. See [Companion events](#companion-events-why-a-cluster-can-look-empty). ## Anchor and member rows `GET /venue-events/{id}` returns the row you asked for. It does not promote a member id to the anchor. Cluster fields are complete from either row. The member's own markets, title, description, image, and slug are that venue's copy. | | Anchor id | Member id | | - | - | - | | `matchedVenueEvents[]`, `matchedVenueMarkets[]`, `settlementDiff` | full cluster | full cluster | | `venueMarkets[]` | the anchor's markets | that member's markets | | `marketCount` | cluster-wide | cluster-wide | | `groupMarketCount` | anchor-scoped | member-scoped | | `title`, `description`, `image`, `slug` | the anchor's | that venue's | ## Market lists: preview vs full The `venueMarkets` array on a **list** item is a preview of up to three markets, not the largest and not the full set. Use `marketCount` for the count. Fetch markets from [List venue markets](/api-reference/markets/list-venue-markets) with `venueEventId`. `venueEventId` is cluster-scoped by default (`cluster=true`). You get every venue's markets for that real-world event, with matched siblings folded into one anchor row, each still carrying `matchedVenueMarkets[]`. Naming any member returns the same page. It is also how you reach a market that exists on only one venue. `cluster=false` is that one venue event's own markets. Pass the same value to `GET /venue-events/{id}` when you want `marketCount` to match. Only the strings `true` and `false` are accepted. Any other value is 400, not the default. `marketCount` counts the open rows that cluster-scoped `GET /venue-markets?venueEventId=` returns. Pass `status=open` on the market list to see that set. Without `status`, the list also returns closed and resolved markets. Rolled-up `volume` sums every market in the cluster, including siblings folded out of the list. The two totals are not over the same set. The embedded market array on event detail is deprecated and is always that one event's markets, whatever `cluster` says. ## Companion events: why a cluster can look empty A venue often splits one game into a base plus companions such as `… - More Markets` or `… : Spread`. They share one `aggKey` and each has its own `id`. Two events from the same venue never share a cluster, except [Hyperliquid deployers](/recipes/hyperliquid-deployers). A companion clusters with the other venues' equivalent companions, not with the game. `matchedVenueEvents[]` can be empty while the game is live on several venues. `groupParentId` non-null is how you tell. ```json theme={null} // GET /venue-events?externalIdentifier=831639&venue=polymarket { "title": "Fulham FC vs. Chelsea FC - More Markets", "aggKey": "agg_epl_che_ful_260824", "groupParentId": "zyqlfv387lv8afebryzby1sp", "venueCount": 1, "matchedVenueEvents": [] } ``` Follow `groupParentId`, or fetch `GET /venue-events?aggKey={aggKey}` for every companion of that game. The game page uses the key fetch. See [Sports market detail](/recipes/sports-market-detail) and [aggKey](/recipes/agg-key). Each market still carries `matchedVenueMarkets[]` on a companion, so a price comparison does not need `matchedVenueEvents[]`. `grouped=true` on the event list folds companions under their base. It is opt-in, because it hides the companions a game page loads again with `aggKey`. ## Related > **[Ids](/concepts/ids)** > > Which id to store, and which id is only the anchor for this page. > **[Comparing prices](/recipes/comparing-venue-prices)** > > Walk matched markets and read midpoints. > **[Orderbook socket](/recipes/websocket-orderbook)** > > Live depth, keyed by outcome id. > **[aggKey](/recipes/agg-key)** > > Optional fetch filter when the key converges. > **[Settlement differences](/recipes/settlement-key-differences)** > > Resolution-criteria differences between venues in a cluster. # News Feed Source: https://docs.agg.market/recipes/news-feed Integrate market-aware news feeds into partner discovery and market detail experiences AGG News Feed connects real-world news to tradeable prediction markets. Use it to power category feeds, market detail pages, related-news modules, and agent workflows with articles that are already linked to markets and summarized for market impact. Instead of showing generic news links, partner apps can show why a story may matter to a specific prediction market. News Feed is an app-tier Discovery API for market-aware articles, live market links, and market-impact summaries. ## Public API access All News Feed endpoints require your app header: **x-app-id** `string` *required* Your AGG app identifier. No user JWT is required. App venue and category settings are applied automatically, so responses only include markets your app is configured to show. ## Endpoint map | API Reference | Use when | | - | - | | [List feeds](/api-reference/news/list-available-news-feeds-with-ids-and-item-counts) | You want the available feed categories and item counts. | | [Get article feed](/api-reference/news/article-centric-news-feed-for-a-category--latest-articles-with-linked-markets-and-live-prices) | You want an article-first feed with linked markets for each article. | | [Get market feed](/api-reference/news/market-centric-news-feed--markets-grouped-with-their-news-timeline) | You want a market-first feed where each market includes recent related articles. | | [Get market news](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries) | You have specific markets or events and want recent articles for them. | Paths: * `POST /news-feed/market-news` — start here when you already have the market. [Get news articles for markets with market impact summaries](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries). * `GET /news-feed` — [List available news feeds with ids and item counts](/api-reference/news/list-available-news-feeds-with-ids-and-item-counts). * `GET /news-feed/{category}` — [Article-centric news feed for a category - latest articles with linked markets and live prices](/api-reference/news/article-centric-news-feed-for-a-category--latest-articles-with-linked-markets-and-live-prices). * `GET /news-feed/{category}/markets` — [Market-centric news feed - markets grouped with their news timeline](/api-reference/news/market-centric-news-feed--markets-grouped-with-their-news-timeline). Available categories are `politics`, `geopolitics`, `world`, `finance`, `climate`, and `entertainment`. ## What you get * Article-first feeds for discovery pages and editorial-style layouts. * Market-first feeds where each market carries its recent article timeline. * On-demand related-news lookup for specific `venueMarketIds` or `venueEventIds`. * Article metadata, publisher/source labels, images when available, publish timestamps, and links to original sources. * LLM-generated market-impact summaries and relevance scores for ranking related articles. * App-scoped filtering so disabled venues and categories do not leak into partner feeds. ## Which call should I use? Use `getMarketNews` when the user is already looking at a specific market or event. This is the call for "related news" modules on market detail pages. Use `getNewsFeed` when you want a category news feed where articles are the main object. Use `getNewsFeedMarkets` when you want a category feed where markets are the main object and each market carries its recent article timeline. Most partner integrations use both patterns: 1. Category or homepage: call `getNewsFeed("finance")` or `getNewsFeedMarkets("finance")`. 2. Market detail page: call `getMarketNews({ venueMarketIds: [market.id] })`. 3. Event detail page: collect the event's market ids, then call `getMarketNews` for the set. ## Which id should I pass? | If your UI has | Pass this field | Example request | | - | - | - | | A market card or market detail object | `venueMarket.id` | `{ "venueMarketIds": ["vm_abc123"] }` | | A matched sibling market | `venueMarket.matchedVenueMarkets[].id` | `{ "venueMarketIds": ["vm_abc123", "vm_def456"] }` | | An event object | `venueEvent.id` | `{ "venueEventIds": ["ve_abc123"] }` | | An event detail with markets loaded | `venueEvent.venueMarkets[].id` | `{ "venueMarketIds": ["vm_1", "vm_2"] }` | ## Choose a feed shape Use the article feed for homepages, discovery rails, and editorial-style layouts: ```typescript theme={null} const { data, nextCursor, hasMore } = await client.getNewsFeed("finance", { limit: 20, }); for (const item of data) { console.log(item.article.title); console.log(item.markets.map((market) => market.question)); } ``` Use the market feed for market pages, event pages, and category views where the market is the main object: ```typescript theme={null} const { data } = await client.getNewsFeedMarkets("politics", { limit: 12, }); for (const market of data) { console.log(market.question); console.log(market.articles.map((article) => article.title)); } ``` ## Pagination and refresh Both feed shapes support the same query options: | Option | Type | Notes | | - | - | - | | `limit` | `number` | Page size from `1` to `50`. | | `cursor` | `string` | Pass the previous response's `nextCursor` to fetch the next page. | | `since` | `string` | ISO timestamp for a lower time bound. | | `before` | `string` | ISO timestamp for an upper time bound. | ```typescript theme={null} const firstPage = await client.getNewsFeed("world", { limit: 20 }); if (firstPage.nextCursor) { const nextPage = await client.getNewsFeed("world", { cursor: firstPage.nextCursor, limit: 20, }); } ``` For repeated refreshes of the same feed or market request, poll no more than once per minute unless the user explicitly asks for a manual refresh. ## Get related news for one market If your UI is rendering a market card or market detail page, you already have the AGG market id at `venueMarket.id`. Pass that id as `venueMarketIds`. ```ts SDK theme={null} const news = await client.getMarketNews({ venueMarketIds: ["vm_abc123"], limit: 3, }); for (const result of news.results) { console.log(result.question); console.log(result.articles.map((article) => article.summary)); } ``` ```bash cURL theme={null} curl -X POST "https://api.agg.market/news-feed/market-news" \ -H "x-app-id: YOUR_APP_ID" \ -H "content-type: application/json" \ --data '{"venueMarketIds":["vm_abc123"],"limit":3}' ``` [Get news articles for markets with market impact summaries](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries). The response is grouped by market. For a single market, render the first result: ```typescript theme={null} const [marketNews] = news.results; const cards = marketNews?.articles.map((article) => ({ headline: article.title, source: article.source, publishedAt: article.publishedAt, href: article.url, imageUrl: article.imageUrl, marketContext: article.summary, })) ?? []; ``` In the UI, label this module as related news for the market question: ```typescript theme={null} const title = marketNews ? `Related news for ${marketNews.question}` : "Related news"; ``` For request and response details, use the [Get market news API reference](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries). ## Include matched venues If your market object includes `matchedVenueMarkets`, include those ids too. This lets a single market detail page show related news for the same market across venues. ```typescript theme={null} const venueMarketIds = [ market.id, ...(market.matchedVenueMarkets ?? []).map((matched) => matched.id), ].slice(0, 10); const relatedNews = await client.getMarketNews({ venueMarketIds, limit: 3, }); ``` Deduplicate articles by `url` before rendering if you merge results from multiple markets: ```typescript theme={null} const uniqueArticles = [ ...new Map( relatedNews.results .flatMap((result) => result.articles) .map((article) => [article.url, article]), ).values(), ]; ``` ## Get related news for an event If the user is on an event detail page, fetch the event's markets first, then request news for the markets you plan to render. ```typescript theme={null} const { data: markets } = await client.getVenueMarkets({ venueEventId: "ve_abc123", status: "open", limit: 10, }); const venueMarketIds = markets .flatMap((market) => [ market.id, ...(market.matchedVenueMarkets ?? []).map((matched) => matched.id), ]) .slice(0, 10); const eventNews = await client.getMarketNews({ venueMarketIds, limit: 2, }); ``` Render each group under its market question: ```typescript theme={null} for (const result of eventNews.results) { console.log(result.question); for (const article of result.articles) { console.log(article.title, article.summary); } } ``` If you only have the event id and do not need market-level control, pass `venueEventIds` directly. For the exact body shape, use the [Get market news API reference](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries). You can pass `venueMarketIds`, `venueEventIds`, or both. The request accepts up to 10 ids total, and `limit` is capped at 5 articles per result. Use `customQuery` only when you want to bias the search toward a user-selected topic. Use `tbs` only when you need to override the time window for a dedicated experience, such as "past 24 hours". ## Integration notes * Feed endpoints are cached for 60 seconds per request and app scope. Do not poll more frequently unless the user explicitly refreshes. * On-demand market-news requests accept up to 10 ids total across `venueMarketIds` and `venueEventIds`. * `limit` is capped at 5 articles per market-news result and 50 rows for feed pages. * `relevanceScore` is a ranking signal within the response, not an absolute quality score across unrelated markets or categories. * News summaries explain why an article may matter to a market; they are not a replacement for the linked source article. ## Response fields to render | Field | Render it as | | - | - | | `result.question` | The market question the articles relate to. | | `result.eventTitle` | Optional event context above the market question. | | `article.title` | The article headline. | | `article.source` | Publisher/source label. | | `article.publishedAt` | Timestamp or recency label. | | `article.url` | External link to the original article. | | `article.summary` | Short explanation of why the article matters for the market. | | `article.relevanceScore` | Sort or filter signal within this response. | ## Render recommendations Show `source`, `publishedAt`, and `url` with each article so users can inspect the original source. Treat `summary` as market-context copy: it explains why the article may matter to the linked market, not a replacement for the article itself. `relevanceScore` is a relative ranking signal. Use it to order or hide low-confidence links inside a single response, but do not compare scores across unrelated pages as an absolute measure. ## Error handling | Status | Handle it as | | - | - | | `400` | The category, ids, or query options are invalid. Fix the request before retrying. | | `401` / `403` | The app header is missing or not allowed for the endpoint. | | `429` | The on-demand market-news request limit was reached. Back off and retry later. | If a category is disabled for your app, feed endpoints return an empty page with `data: []`, `nextCursor: null`, and `hasMore: false`. ## FAQ ### Is News Feed just a list of article links? No. News Feed links articles to specific prediction markets and includes market-impact summaries so apps can show why a story may matter to a tradeable market. ### Should I use article-first or market-first feeds? Use article-first feeds when the news story is the main object, such as a homepage or discovery rail. Use market-first feeds when the market is the main object, such as a market page, event page, or category page built around trading opportunities. ### Can agents use News Feed? Yes. Agents can use News Feed as structured market context: article metadata, market ids, summaries, relevance scores, and live linked-market prices are returned in API responses. ## Related > **[API Reference](/api-reference/news/list-available-news-feeds-with-ids-and-item-counts)** > > View schemas and try the News Feed endpoints. > **[Building Market Views](/recipes/building-market-views)** > > Compose Discovery endpoints into category pages, market details, and event grids. # Onramp Source: https://docs.agg.market/recipes/onramp Quote a fiat-to-crypto purchase and open the provider checkout for a supported wallet. `POST /ramp/quotes` and `POST /ramp/session` require `x-app-id`; a user token is not required. The purchase sends crypto to `walletAddress`. To fund an AGG managed balance, first obtain the correct address and supported chain from [Funding](/concepts/funding). `sourceAmount` is a decimal string in `sourceCurrencyCode` units (`"50"` is fifty dollars, not atomic units). ```ts SDK theme={null} const quotes = await client.getRampQuotes({ sourceAmount: "50", sourceCurrencyCode: "USD", destinationCurrencyCode: "USDC", }); if (!quotes.length) throw new Error("No onramp quote is available for this purchase"); const session = await client.createRampSession({ sourceAmount: "50", sourceCurrencyCode: "USD", destinationCurrencyCode: "USDC", serviceProvider: quotes[0].serviceProvider, walletAddress, }); // session.widgetUrl is the checkout to open ``` ```bash cURL theme={null} curl -s -X POST -H "x-app-id: $APP_ID" -H "Content-Type: application/json" \ -d '{"sourceAmount":"50","sourceCurrencyCode":"USD","destinationCurrencyCode":"USDC"}' \ "$API/ramp/quotes" curl -s -X POST -H "x-app-id: $APP_ID" -H "Content-Type: application/json" \ -d '{"sourceAmount":"50","sourceCurrencyCode":"USD","destinationCurrencyCode":"USDC","serviceProvider":"'"$PROVIDER"'","walletAddress":"'"$WALLET"'"}' \ "$API/ramp/session" ``` Full shapes on [Get crypto purchase quotes](/api-reference/funding/get-crypto-purchase-quotes) and [Create a ramp widget session for checkout](/api-reference/funding/create-a-ramp-widget-session-for-checkout). # Paper Trading Source: https://docs.agg.market/recipes/paper-trading Simulated trading against live market data, for user-facing paper portfolios, testing, and QA. Two paths. They are not interchangeable. | Path | Who calls it | Auth | | - | - | - | | `mode: "paper"` on the managed quote and the fill | Your app, with the user's session | `x-app-id` and `Authorization: Bearer`. No API key. | | Paper account admin | Your backend | `x-app-api-key` only | Only managed quote and fill accept `mode: "paper"`. Direct orders reject it, and an app in paper trading mode gets `400` with `code: "paper_mode_unsupported"` from `POST /execution/orders`. Reads listed below take the same `mode` so paper rows are not mixed with live ones. Paper trading prices quotes against the current book and writes simulated balances, orders, and positions. It never submits to a venue and never runs a bridge, custody transfer, deposit, withdrawal, or redemption. Do not show a paper balance as a funded wallet. The signed-in user's paper account is the one whose `externalId` is `user:`. It is created the first time they request paper balances, positions, a quote, or a fill. Keep paper and live state visually separate. ## User quote and fill A paper quote must be filled with `mode: "paper"`. A live fill cannot execute a paper quote. Switching modes needs a fresh quote. ```ts SDK theme={null} // After the user signs in. This client has no apiKey. const quote = await client.getSmartRoute({ venueMarketOutcomeId, // id from venueMarketOutcomes[].id tradeSide: "buy", maxSpend: 25, mode: "paper", }); await client.executeManaged({ quoteId: quote.quoteId, mode: "paper" }); ``` ```bash cURL theme={null} # Sell sends sellShares and omits maxSpend. Do not send both. No API key. curl -s -G "$API/orderbook/$OUTCOME_ID/route" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "maxSpend=25" \ --data-urlencode "mode=paper" curl -s -X POST "$API/execution/fill" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"quoteId":"'"$QUOTE_ID"'","mode":"paper"}' ``` [Get a quote](/api-reference/trading/get-a-quote-smart-route) and [Execute quote](/api-reference/trading/execute-quote). Poll [Get execution status](/api-reference/trading/get-execution-status) with the same `mode` until `terminal` is true. Pass `mode: "paper"` on these reads as well: | SDK method | API reference | | - | - | | `getManagedBalances({ mode: "paper" })` | [Get balances](/api-reference/portfolio/get-balances) | | `getExecutionStatus({ quoteId, mode: "paper" })` | [Get execution status](/api-reference/trading/get-execution-status) | | `getExecutionPositions({ mode: "paper" })` | [Get user positions](/api-reference/portfolio/get-user-positions) | | `getExecutionOrders({ mode: "paper" })` | [Get user orders](/api-reference/portfolio/get-user-orders) | `POST /execution/orders` is live only. It has no `mode` field and rejects one with `400`. ## UI components Set paper mode once on `AggProvider`. `HomePage`, `EventMarketPage`, `PlaceOrder`, and `UserProfilePage` read it when no `executionMode` prop is passed. ```tsx theme={null} import { AggProvider } from "@agg-build/ui"; import { QueryClient, QueryClientProvider } from "@agg-build/hooks"; import { HomePage } from "@agg-build/ui/pages"; const queryClient = new QueryClient(); export function App() { return ( ); } ``` Pass `executionMode` on one component when it must differ from the provider. Hide deposit, withdrawal, and redemption controls in a paper-only experience. ## Admin accounts These methods are on `AggClient`, not a separate admin client. Configure `apiKey` on a server-side client. Never call them from the browser. The route authorizes `x-app-api-key` only. Do not send a user bearer token. The signed-in user sees the account whose `externalId` is `user:`. `externalId` is unique per app. Creating one that already exists returns a validation error. Look it up with [List paper trading accounts](/api-reference/paper-trading/list-paper-trading-accounts) instead of creating it again. ```ts SDK theme={null} // Server-side only. { appId } selects the account path. const agg = createAggClient({ baseUrl: process.env.AGG_API_BASE_URL!, appId: "your-app-id", apiKey: process.env.AGG_APP_API_KEY!, }); await agg.createPaperTradingAccount( { externalId: `user:${aggUserId}`, initialBalanceRaw: "100000000", // 6-decimal USD. 1000000 = $1 }, { appId: "your-app-id" }, ); ``` ```bash cURL theme={null} curl -s -X POST "$API/apps/$APP_ID/paper-trading/accounts" \ -H "x-app-api-key: $AGG_APP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"externalId":"user:the-agg-user-id","initialBalanceRaw":"100000000"}' ``` [Create a paper trading account](/api-reference/paper-trading/create-a-paper-trading-account). Additional paper-account operations: | SDK method | API reference | | - | - | | `listPaperTradingAccounts` | [List paper trading accounts](/api-reference/paper-trading/list-paper-trading-accounts) | | `getPaperTradingAccount` | [Get a paper trading account](/api-reference/paper-trading/get-a-paper-trading-account) | | `setPaperTradingBalance` | [Set a paper trading account cash balance](/api-reference/paper-trading/set-a-paper-trading-account-cash-balance) | | `resetPaperTradingAccount` | [Reset a paper trading account](/api-reference/paper-trading/reset-a-paper-trading-account) | | `placePaperTradingOrder` | [Place a paper trading order](/api-reference/paper-trading/place-a-paper-trading-order) | | `listPaperTradingOrders` | [List paper trading orders](/api-reference/paper-trading/list-paper-trading-orders) | | `listPaperTradingPositions` | [List paper trading positions](/api-reference/paper-trading/list-paper-trading-positions) | | `getPaperTradingPortfolio` | [Get a paper trading portfolio](/api-reference/paper-trading/get-a-paper-trading-portfolio) | `resetPaperTradingAccount` clears open positions and sets cash. Order history survives a reset. `setPaperTradingBalance` changes cash only. Amounts are 6-decimal integer strings. `initialBalance`, `balance`, and `spend` also accept numbers. Prefer the raw string for a repeatable fixture. On `placePaperTradingOrder`, `clientOrderId` is stored for traceability only. It is not an idempotency key. The order prices against the current book and comes back filled or rejected. ## Testing Sign in a test user, read paper balances and check `PAPER_USD` is present, quote an open market with `mode: "paper"`, fill it, and poll to a terminal state. Then read paper orders and positions. Negative paths: fill a paper quote without `mode: "paper"` and expect a rejection. Switch from paper to live only with a fresh quote. Paper pages show no deposit, withdrawal, bridge, or redemption. For a repeatable run, reset the `user:` account first, pick outcomes from current discovery, and use a scenario-owned `clientOrderId` on backend orders so logs map fixtures to orders. # Partner admin Source: https://docs.agg.market/recipes/partner-admin Read your app's users, balances, positions, orders, deposits, withdrawals, and analytics from your backend. Use these endpoints for reporting and reconciliation from your backend. Authenticate with `x-app-api-key`; `x-app-id` and a user token are not required. The `{appId}` in the path must match the key's app. A `read` key can access every user in that app. A `trade` key is rejected on these routes: it can trade as a signed-in user, and it cannot list users or orders. First [create a server API key](/environments#server-api-keys). For a signed-in user's activity screen or the app leaderboard, follow [Portfolio](/recipes/portfolio). `AggAdminClient` provides methods for users, orders, analytics, and market-resolution holders. Use `admin.request()` for the per-user reads below. Manage app members and settings in the admin dashboard. ```ts theme={null} import { AggAdminClient } from "@agg-build/sdk/server"; const admin = new AggAdminClient({ baseUrl: "https://api.agg.market", apiKey: process.env.AGG_APP_API_KEY!, }); ``` ## Users ```ts SDK theme={null} const users = await admin.listUsers(appId, { limit: 20 }); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users?limit=20" ``` Full shape on [List users](/api-reference/partner-admin/list-users). ```ts SDK theme={null} const user = await admin.getUser(appId, userId); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID" ``` Full shape on [Get user](/api-reference/partner-admin/get-user). ## Balances, positions, orders ```ts SDK theme={null} const balances = await admin.request(`/apps/${appId}/users/${userId}/balances`); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID/balances" ``` Full shape on [Get user balances](/api-reference/partner-admin/get-user-balances). ```ts SDK theme={null} const positions = await admin.request(`/apps/${appId}/users/${userId}/positions`); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID/positions" ``` Full shape on [Get user positions](/api-reference/partner-admin/get-user-positions). ```ts SDK theme={null} const orders = await admin.request(`/apps/${appId}/users/${userId}/orders`); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID/orders" ``` Full shape on [Get user orders](/api-reference/partner-admin/get-user-orders). ```ts SDK theme={null} const feed = await admin.listOrders(appId, { limit: 50 }); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/orders?limit=50" ``` Full shape on [List orders across your app](/api-reference/partner-admin/list-orders-across-your-app). This feed is ordered by last update. Upsert by `id`. ## Deposits and withdrawals ```ts SDK theme={null} const deposits = await admin.request(`/apps/${appId}/users/${userId}/deposits`); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID/deposits" ``` Full shape on [Get user deposits](/api-reference/partner-admin/get-user-deposits). `amountRaw` is an integer string in the token's decimals. ```ts SDK theme={null} const withdrawals = await admin.request(`/apps/${appId}/users/${userId}/withdrawals`); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/users/$USER_ID/withdrawals" ``` Full shape on [Get user withdrawals](/api-reference/partner-admin/get-user-withdrawals). ## Analytics and resolution ```ts SDK theme={null} const analytics = await admin.getAnalytics(appId); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/analytics" ``` Full shape on [Get analytics](/api-reference/partner-admin/get-analytics). Cash and position values reflect current holdings; fees are cumulative. `usersLast7Days`, `usersLast30Days`, `ordersLast7Days`, and `ordersLast30Days` cover the named rolling windows. ```ts SDK theme={null} const holders = await admin.listMarketResolutionUsers(appId, venueMarketId, { includeEmails: true, }); ``` ```bash cURL theme={null} curl -s -H "x-app-api-key: $AGG_APP_API_KEY" \ "$API/apps/$APP_ID/market-resolutions/$VENUE_MARKET_ID/users?includeEmails=true" ``` Full shape on [List users affected by market resolution](/api-reference/partner-admin/list-users-affected-by-market-resolution). # Trusted Proxy Source: https://docs.agg.market/recipes/partner-trusted-proxy Forward end-user IPs to AGG for accurate per-user geo enforcement when proxying through your backend Opt-in mode for partners proxying AGG API calls through your own backend. When enabled on your app, AGG resolves user geo from `x-agg-client-ip` (populated by your backend) instead of from the request's connecting IP. Enabled by Snag, per app. Once on, every active API key on the app is trusted. ## Requirements Before requesting enablement: 1. `requireApiKey: true` must be set on your app (Security Settings in the partner dashboard). While trusted-proxy is enabled, this cannot be disabled. 2. Every request must include `x-app-api-key`. Requests with only `x-app-id` go through standard geo resolution even when trusted-proxy is enabled. 3. Your edge proxy must strip or overwrite any inbound `x-agg-client-ip` header. If an end user can pass `x-agg-client-ip` through your proxy, geo enforcement is defeated. ## Header rules Set `x-agg-client-ip` on every request your backend forwards to AGG: * Value must be the end user's client IP as your server observed it. * Your proxy must strip or overwrite any inbound `x-agg-client-ip`. * Single IP value. Comma-separated lists are rejected. * IPv4 or IPv6, public addresses only. Private/reserved ranges resolve to null. On a fill, send the header from your proxy. The browser SDK does not set it. ```ts SDK theme={null} // x-agg-client-ip is set by your proxy, not by the browser SDK. const fill = await client.executeManaged({ quoteId }); ``` ```bash cURL theme={null} curl -X POST https://api.agg.market/execution/fill \ -H "x-app-id: $AGG_APP_ID" \ -H "x-app-api-key: $AGG_APP_API_KEY" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "x-agg-client-ip: 203.0.113.10" \ -H "content-type: application/json" \ -d '{ "quoteId": "QUOTE_ID" }' ``` [Execute quote](/api-reference/trading/execute-quote). ## Behaviour * Trusted app + valid `x-app-api-key` + valid `x-agg-client-ip`: AGG resolves country from that IP. * A missing or bad `x-agg-client-ip` falls back to the backend IP, and the fill fail-closes. See [Venue geo](/recipes/venue-geo-availability). * Trusted app + missing `x-app-api-key`: rejected by `requireApiKey` enforcement before geo runs. * App not trusted: standard geo resolution; `x-agg-client-ip` is ignored if present. ## Responsibilities Enabling trusted-proxy on your app shifts geo enforcement responsibility from AGG to you. By requesting enablement, you assert: * Every `x-agg-client-ip` value AGG receives originates from your server's observation of the end-user connection. * End users cannot inject `x-agg-client-ip` through your proxy. * You are responsible for geo compliance for your users while trusted-proxy is enabled. If a user reaches a venue they shouldn't because your proxy forwarded a spoofed or stale IP, the responsibility sits with your integration. ## Limitations * Country resolution only. No region or state granularity. See [Venue geo](/recipes/venue-geo-availability). * VPN exits resolve to the VPN's exit country. Mobile carrier and satellite ranges are occasionally misclassified. * The flag is per-app. When enabled, every active API key on the app is trusted. There is no per-key opt-in. ## Requesting enablement Email `support@agg.market` with: * The app ID. * Confirmation that `requireApiKey: true` is set on the app. * Confirmation that your proxy strips inbound `x-agg-client-ip`. # Portfolio Source: https://docs.agg.market/recipes/portfolio Read the signed-in user's activity feed and the app leaderboard. Positions and orders are on Order lifecycle. Activity is the signed-in user (`x-app-id` and `Authorization: Bearer`). The leaderboard is the app (`x-app-id` only). Positions and orders are poll targets on [Order lifecycle](/concepts/order-lifecycle): `GET /execution/positions` and `GET /execution/orders`. The UI is [Leaderboard](/components/pages/leaderboard-page). For a profile screen, add [user avatars](/recipes/avatar-upload). For backend reporting across all users in your app, use [Partner admin](/recipes/partner-admin) with a server API key. ## Activity `type` is `trade`, `withdrawal`, `bridge`, `deposit`, `user_op`, or `redeem`. ```ts SDK theme={null} const activity = await client.getUserActivity({ type: "trade", limit: 20 }); ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/users/activity?type=trade&limit=20" ``` Full shape on [Get user activity feed](/api-reference/portfolio/get-user-activity-feed). ## Leaderboard `metric` is `realizedPnl` (default) or `volume`. `rank` is exact inside the top 100 and null after that. Pass exactly one of `userId`, `externalId`, `username`, or `walletAddress` to look up one user. ```ts SDK theme={null} const board = await client.getLeaderboard({ metric: "realizedPnl", limit: 20 }); ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" \ "$API/leaderboard?metric=realizedPnl&limit=20" ``` Full shape on [Trader leaderboard](/api-reference/portfolio/trader-leaderboard). ## One user's P\&L over time `GET /leaderboard/series` returns one user's daily `orders`, `volume`, `spent`, `realizedPnl`, and a running `cumulativeRealizedPnl`, oldest first. Pass exactly one of `userId`, `externalId`, `username`, or `walletAddress`. The default range is the last 30 days, and the maximum is 366. Days with no activity return `0`. Unrealized P\&L on open positions is not included. ```ts SDK theme={null} const series = await client.getLeaderboardSeries({ externalId, from: "2026-09-01" }); ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" \ "$API/leaderboard/series?externalId=$EXTERNAL_ID&from=2026-09-01" ``` Full shape on [Trader leaderboard series](/api-reference/portfolio/trader-leaderboard-series). # Privy Wallet Provider Source: https://docs.agg.market/recipes/privy-wallet-provider Use your existing Privy wallets for authentication and trade execution in AGG. ## Overview > [!NOTE] > > **What this integration does** > > When Privy is configured as your wallet provider: > > * **Authentication** — users sign in to AGG with their Privy access token. > * **Execution** — AGG signs trades with the wallets in your Privy app. > > Your users keep the same wallet addresses and balances. > **Authentication** > > Privy access tokens > **Wallets** > > Your existing Privy wallets > **Chains** > > Ethereum + Solana Both effects come from one setup in one Privy app. Privy sign-in is not available on its own, because AGG finds a user's Privy wallets through the Privy identity that sign-in records. ## Quick start **1. Prepare wallets** Make sure every trading user has an Ethereum and a Solana wallet that AGG can sign with. See [Wallet ownership](#wallet-ownership). **2. Configure AGG** Save your Privy credentials under **Settings → Wallet Provider** and click **Validate**. See [Configure Privy in AGG](#configure-privy-in-agg). **3. Authenticate the user** Exchange the user's Privy access token for an AGG session. See [Authentication & sessions](/concepts/authentication) and [Authenticate users](#authenticate-users). **4. Deposit addresses** Request them from [Funding & withdrawals](/concepts/funding). That page owns the poll. For a Privy wallet-provider app, `evmAddress` is the authorized Privy wallet. **5. Fund and trade** Send USDC to the addresses Funding returns, and place orders as usual. ```mermaid theme={null} flowchart TD A[Privy SDK in your app] -->|access token| B[AGG session] B --> C[Deposit addresses on Funding] C --> D[Fund and trade] ``` ## Prerequisites > [!NOTE] > > **Before you start**, you need: > > * Your Privy **App ID** > * Your Privy **App Secret** > * Your Privy **Authorization Private Key** > * A Privy **Signer ID** — only if AGG should create missing wallets > * An **Ethereum and a Solana wallet** for every trading user | Value | Where in Privy | Secret? | | - | - | - | | **App ID** | App settings | No — it is the `aud` claim and part of a public JWKS URL | | **App Secret** | App settings | Yes | | **Authorization Private Key** | Wallet API → Authorization keys | Yes | | **Signer ID** (key quorum ID) | Wallet API → Key quorums | No | > [!WARNING] > > **Privy shows the authorization private key only once.** Store your own copy > before you leave the Privy dashboard. If you lose it, create a new > authorization key in Privy and save it in AGG again. The key is accepted in any of the shapes Privy hands out: the `wallet-auth:MIGH...` form, a bare base64 PKCS#8 body, or a full `-----BEGIN PRIVATE KEY-----` PEM block. ## How it works 1. **Sign-in.** Your frontend gets an access token from Privy and posts it to AGG. AGG verifies it against your Privy app's public keys and returns an AGG session. There is no callback URL: Privy never redirects. 2. **Wallet setup.** The first time a user's deposit addresses are requested, AGG finds that user's Ethereum and Solana wallets in your Privy app and links them. If you opted in, it creates any that are missing. 3. **Execution.** Every trade, bridge and transfer is signed through Privy with the authorization key you saved, with no user present. ## Wallet ownership AGG can only use a wallet it can sign with on its own: | Wallet configuration | Supported | Requirement | | - | :-: | - | | User-owned + server signer | ✅ | Your key quorum is attached as an additional signer and contains the authorization key saved in AGG | | Server-owned | ✅ | The owner is your authorization key, or a key quorum containing it | | User-owned without server signer | ❌ | Would need the user's own session to sign — AGG rejects it with `PRIVY_WALLET_NOT_SIGNABLE` | A user-owned wallet with your server signer looks like this in Privy: ```json theme={null} { "owner": { "user_id": "did:privy:…" }, "additional_signers": [{ "signer_id": "" }] } ``` > [!WARNING] > > **Important — AGG must sign without the user being present.** Limit-order > fills, settlement webhooks, and managed balance refills run after the > original user request has ended, some of them minutes or hours later. > [!TIP] > > If some existing wallets do not have your server signer yet, the user has to > add it from your app — see > [Existing wallets without your signer](#existing-wallets-without-your-signer). > Also check that no Privy policy blocks your authorization key. ## Wallet discovery AGG looks for a user's wallets in two places, so either ownership model works without extra configuration: | Wallet type | Where AGG finds it | | - | - | | User-owned | The user's linked accounts in Privy. No `entity` needed. | | Server-owned | `entity: { id: "", type: "user" }` | Of the wallets found, AGG uses the first one per chain that it can actually sign with, proven by signing a harmless message. > [!NOTE] > > Entity is **immutable once set**. A server-owned wallet created without one can > never be attributed to a user. ## Wallet creation By default AGG **never creates a wallet** — it uses the ones your onboarding already made. If your users can reach trading before a wallet exists, turn on **Create wallets for users who have none** and enter your **Signer ID**. AGG then creates the missing wallet as a user-owned wallet with your signer attached: ```json theme={null} { "chain_type": "ethereum", "owner": { "user_id": "did:privy:…" }, "additional_signers": [{ "signer_id": "" }] } ``` Use the key quorum your backend already attaches to your users' wallets, so your signer policies and key rotation also cover wallets AGG creates. AGG never creates an app-owned wallet, and the setting cannot be saved without a Signer ID. > [!NOTE] > > **AGG does not replace existing wallets.** Creation only fills a chain where > the user has no wallet. If AGG cannot sign with an existing wallet, it reports > that instead of creating a second one, because your user's funds may be in the > wallet it skipped. > [!WARNING] > > **Avoid duplicate wallets.** If your onboarding already creates wallets — > possibly after the user has signed in to AGG — leave **Create wallets for > users who have none** disabled. Otherwise both sides can create a wallet for > the same chain. Unlike the wallet provider itself, this setting can be changed at any time. A change applies the next time a user's wallets are set up. ## Existing wallets without your signer Embedded wallets that Privy created at signup are **owned by the user**. If your key quorum was never added to them, AGG cannot sign with them, and `GET /execution/deposit-addresses` answers `409` with `code: "PRIVY_WALLET_NOT_SIGNABLE"`. **Create wallets for users who have none** does not help here — it only fills a chain with no wallet, and never replaces one your user may already have funded. Only the wallet's owner can change its signers, so your server key cannot add the signer on its own. Add it from your frontend, with the user's Privy session, using Privy's `useSigners` hook: ```tsx theme={null} import { usePrivy, useSigners } from "@privy-io/react-auth"; // YOUR Privy key quorum ID — the same value you entered as "Signer ID" under // Settings → Wallet Provider in AGG. It is not issued by AGG: it comes from // your Privy dashboard, and the quorum must contain the authorization key // you saved in AGG. const YOUR_PRIVY_SIGNER_ID = ""; export function useAddSignerToWallets() { const { user } = usePrivy(); const { addSigners } = useSigners(); return async function addSignerToWallets() { // Your users' Privy embedded wallets, on both chains — AGG signs on each. const wallets = (user?.linkedAccounts ?? []).filter( (account) => account.type === "wallet" && account.walletClientType === "privy", ); for (const wallet of wallets) { try { await addSigners({ address: wallet.address, signers: [{ signerId: YOUR_PRIVY_SIGNER_ID }], }); } catch (err) { // Privy rejects adding a signer the wallet already has. That means this // wallet is already set up — skip it and continue with the next one. if (String((err as Error)?.message).includes("Duplicate signer")) continue; throw err; } } }; } ``` Call it after sign-in, before you request deposit addresses, or when that request returns `PRIVY_WALLET_NOT_SIGNABLE`. Deposit address hooks surface the same code as `error`. The request itself is on [Funding & withdrawals](/concepts/funding). ### What to expect | | | | - | - | | **User prompt** | None. `addSigners` runs headlessly — the signed-in session is the authorization. To ask for consent, show your own message first. | | **Calling it twice** | Privy rejects the repeat with a `Duplicate signer` error and leaves the wallet unchanged. The example treats it as done. | | **Wallets and funds** | Unchanged. AGG links the user's existing wallets. | | **Deposit addresses** | Can take up to 5 minutes after a `409` — AGG rechecks the wallet once its cached refusal expires. | > [!TIP] > > On older versions of Privy's React SDK without `useSigners`, use > `useSessionSigners().addSessionSigners` — it takes the same arguments. Users who have no wallet yet do not need this: with **Create wallets for users who have none** turned on, AGG creates their wallets with your signer already attached. ## Configure Privy in AGG **1. Open Wallet Provider settings** In the AGG dashboard, go to **Settings → Wallet Provider**. **2. Choose Privy** Change the provider from **Portal (default)** to **Privy**. **3. Enter credentials** Enter the App ID, App Secret, and Authorization Private Key. To let AGG create missing wallets, also turn on **Create wallets for users who have none** and enter your Signer ID. **4. Save** Secrets are encrypted before storage and never returned to the dashboard — the form only shows whether they are set. **5. Validate** Checks your credentials against Privy from the service that signs trades, so a typo fails here instead of during a trade. **6. Allow your domains** Under **Domains**, add every origin your frontend calls AGG from. Browser requests from other origins are rejected with `403 Origin not allowed for this app`. Server-to-server calls are not affected. ## Authenticate users Get the user's access token from Privy's client SDK, then exchange it for an AGG session with `POST /auth/verify` and `{ "kind": "privy", "privyToken" }`. There is no Privy setting on the **Auth** tab — saving Privy as the wallet provider enables it, and there is no callback URL. See [Authentication & sessions](/concepts/authentication). AGG verifies the token with Privy's published public keys at `https://auth.privy.io/api/v1/apps/{appId}/jwks.json`. The Privy DID (`sub`) becomes the user's identity, so the same person signing in to two of your apps gets one identity across both. > [!NOTE] > > Signing in does **not** set up the user's wallets. Deposit addresses are on > [Funding & withdrawals](/concepts/funding). For a Privy wallet-provider app, > `evmAddress` is the authorized Privy wallet. ### Sign-in errors Every failure is a `401`: | Message | Meaning | | - | - | | `Privy sign-in is not enabled for this app` | The app's wallet provider is not Privy, or no Privy credentials are saved. | | `Web3 authentication failed` | The token is expired, malformed, or issued by a different Privy app than the one saved under Wallet Provider. | ## Initialize user wallets For a Privy wallet-provider app, the deposit address is the authorized Privy wallet: `evmAddress` on EVM and `svmAddress` on Solana. Other apps get a managed deposit address. Follow the polling example in [Funding & withdrawals](/concepts/funding) until provisioning is ready. A `409` means the Privy wallet does not grant Agg's authorization key signing rights. Add your signer, then request addresses from Funding. See [Existing wallets without your signer](#existing-wallets-without-your-signer). The [`wallets.ready`](/recipes/webhooks/event-reference) webhook fires once a user's wallets are set up. ## Provider lock > [!WARNING] > > **The wallet provider cannot be migrated.** Once any wallet exists for one of > your app's users, the provider is permanently locked. To change providers, > create a new AGG app. > > The two custodians hold key material in different systems and neither exports > it, so switching would strand every existing wallet and the funds in it. The > rule is enforced by the API, not just the dashboard. ## Errors & troubleshooting | Problem | Likely cause | Fix | | - | - | - | | **Validate** returns 401 | The credentials belong to different Privy apps | Re-copy all three credentials from the same Privy app | | `Web3 authentication failed` on sign-in | The token was issued by another Privy app | Check the token comes from the same Privy app whose credentials you saved in AGG | | `409` `PRIVY_WALLET_NOT_SIGNABLE` from deposit addresses | The user's existing wallet does not have your key quorum as a signer | Add it from the user's session — see [Existing wallets without your signer](#existing-wallets-without-your-signer) | | `Duplicate signer(s) provided when updating wallet` | Your signer is already on that wallet | Nothing to do — treat it as success | | A user has two wallets on one chain | Your onboarding and AGG both created one | Disable **Create wallets for users who have none** | | `403 Origin not allowed for this app` | Your frontend domain is not allowed | Add it under **Domains** | | Validate passes but trades fail | A Privy policy or quorum member blocks the authorization key | Review the wallet's policies and key quorum in Privy | # Referrals Source: https://docs.agg.market/recipes/referrals Who pays a referral fee, and how to sign an app-paid payout after the fill is already terminal. A referral pays `referrer` from the same trade. On a sell it applies only when you set a sell rate. Rates and the parameter table live on [Fees](/concepts/fees). Signature payload types live on [Self-custody signing](/recipes/self-custody-signing). Send referral fields from your backend with `x-app-api-key`, alongside `x-app-id` and the user's access token. The API key does not replace the user's session. Never expose it in browser code. ## Who pays `referralPayer` is `"user"` (default) or `"app"`. A user-paid referral is reserved from `maxSpend` and collected the same way as the app fee: on the amount actually filled. `feeBreakdown.appFee` is the maximum if the buy fills in full. `feeBreakdown.referralFee` is the user-paid referral. An app-paid referral is not a user cost. A partial fill pays the quoted referral amount. A failed fill pays nothing. You pay it from `referralPayerAddress`. Per-venue rates go in `referrerFeeBipsByVenue`. Hyperliquid charges 1 USDC to activate a referrer with no HyperCore account. On a self-custody Hyperliquid buy with a user-paid referral, `referralActivationSponsorAddress` names a wallet of yours that pays it instead, and its signature is listed with `purpose: "hl_activation_sponsor"`. See [Fees](/concepts/fees#where-a-referral-cannot-be-paid). ## Poll past terminal An app-paid payout can appear in `pendingSignatures` with `purpose: "referral_payout"` after the trade is already terminal, including for managed trades. When you requested an app-paid referral, continue polling for that payout after a successful fill. Read each request's `expiresAt`; an unanswered request expires without undoing the trade. ```ts SDK theme={null} let payout; const deadline = Date.now() + 15 * 60 * 1000; while (!payout && Date.now() < deadline) { const status = await client.getExecutionStatus({ quoteId }); payout = status.pendingSignatures?.find( (item) => item.purpose === "referral_payout" && item.signerAddress.toLowerCase() === payerAddress.toLowerCase(), ); if (status.terminal && ["failed", "cancelled", "expired"].includes(status.overallState)) break; if (!payout) await new Promise((resolve) => setTimeout(resolve, status.pollAfterMs ?? 3000)); } ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/execution/status?quoteId=$QUOTE_ID" ``` Full shape on [Get execution status](/api-reference/trading/get-execution-status). Submit the signature on [Submit signatures for a pending self-custody fill](/api-reference/trading/submit-signatures-for-a-pending-self-custody-fill). How to sign `payload` is [Self-custody signing](/recipes/self-custody-signing). On an EVM chain the request `type` is `transaction` and you send `{ stepId, txHash }` after broadcasting. On HyperCore (`1337`) the type is `eip712` and you send `{ stepId, signature }`. Solana payouts are refused. ```ts theme={null} // payerSigner uses the signing-reference implementation with your payer wallet. // For transactions it switches chains and converts the decimal value to bigint. if (payout && Date.parse(payout.expiresAt) > Date.now()) { const signed = await payerSigner(payout); await client.submitFillSignatures(quoteId, [ typeof signed === "string" ? { stepId: payout.stepId, signature: signed } : { stepId: payout.stepId, txHash: signed.txHash }, ]); } ``` In self-custody the same `pendingSignatures` list also holds the trader's requests. Skip a request whose `signerAddress` is not the wallet you hold. # Self-Custody Trading Source: https://docs.agg.market/recipes/self-custody Let users fund and sign trades with their own wallet instead of a managed balance > [!NOTE] > > **You'll need:** a signed-in user ([Authentication & sessions](/concepts/authentication)) with an > EVM wallet [linked](/recipes/account-linking) 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](/integration-options). Kalshi uses the user's own key from [Venue keys](/recipes/venue-keys). What to sign is on [Self-custody signing](/recipes/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](/recipes/self-custody-signing#driving-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](/recipes/self-custody-signing#timeouts-and-what-expiry-means). How to write the signer, what each request type asks for, and how many prompts to expect are in the [Self-custody signing reference](/recipes/self-custody-signing). ## 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](#solana). Two refusals appear here, both `400` with a `message` and **no** `code`: | Message | Meaning | | - | - | | `Cannot trade in self-custody: link a wallet to this account first` | The account has no linked EVM wallet. | | `Wallet 0x… is not linked to this account` | The account has linked wallets, but not this one. The address is lowercased in the message. | ## 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](#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](/recipes/self-custody-signing#a-complete-signer), then call `fillSelfCustody` to execute and answer the signing requests. The cURL flow requires the [manual signing loop](/recipes/self-custody-signing#driving-the-loop-yourself) after the fill. `signingAddress` on the fill must match the quoted wallet. Paper mode does not support it. Referral fields are on [Fees](/concepts/fees#referral-fee). Omit `approveMode`, or send `approveMode: "sponsored"`, unless you need [the other approval](#choosing-an-approve-mode). ```ts SDK theme={null} import type { SmartRouteResponse } from "@agg-build/sdk"; type SelfCustodyQuote = SmartRouteResponse & { custodyWarnings?: { reason: string; message: string }[]; }; // client is authenticated and was created with { signer: mySigner }. const quote = await client.request(`/orderbook/${encodeURIComponent(outcomeId)}/route`, { query: { signingAddress: wallet, side: "buy", maxSpend: "25" }, }); if (quote.status !== "ok") { throw new Error(quote.error ?? `Quote unavailable: ${quote.status}`); } if (quote.custodyWarnings?.length) { throw new Error(quote.custodyWarnings.map((warning) => warning.message).join("; ")); } // Show the quote, fees, and warnings and obtain the user's confirmation before filling. await client.fillSelfCustody({ quoteId: quote.quoteId, signingAddress: wallet, }); ``` ```bash cURL theme={null} curl -s -G "$API/orderbook/$OUTCOME_ID/route" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ --data-urlencode "signingAddress=$SIGNING_ADDRESS" \ --data-urlencode "maxSpend=25" curl -s -X POST "$API/execution/fill" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"quoteId":"'"$QUOTE_ID"'","signingAddress":"'"$SIGNING_ADDRESS"'"}' ``` [Get a quote](/api-reference/trading/get-a-quote-smart-route) and [Execute quote](/api-reference/trading/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=`; 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: ```ts theme={null} { messageBase64: string; // the serialized transaction MESSAGE; this is what gets signed transactionBase64: string; // the full unsigned VersionedTransaction, for wallets that // need one to display or simulate chainId: 792703809; blockhash: string; lastValidBlockHeight: number; summary: string[]; // one line per instruction group, for your UI } ``` Return `0x` + 128 hex chars: the 64-byte ed25519 signature over the decoded message. With a wallet adapter: ```ts theme={null} import { VersionedTransaction } from "@solana/web3.js"; case "solana_transaction": { const { transactionBase64 } = req.payload as { transactionBase64: string }; const tx = VersionedTransaction.deserialize(Buffer.from(transactionBase64, "base64")); const signed = await wallet.signTransaction(tx); const index = signed.message.staticAccountKeys.findIndex( (key) => key.toBase58() === req.signerAddress, ); if (index === -1) throw new Error(`signer ${req.signerAddress} is not a signer of this tx`); return `0x${Buffer.from(signed.signatures[index]).toString("hex")}`; } ``` 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](/concepts/fees)), but they are collected differently: | Funding | Collected | Partial fill | | - | - | - | | EVM wallet | Before the fill, on the **quoted** notional, inside the same batch the user signs for funding | Over-collects. The refund is yours to make. | | HyperCore balance (Hyperliquid) | After the trade settles, on the **realized** notional, as extra signatures | Collects nothing | | Polymarket buy paid from the deposit wallet or a legacy Polymarket wallet | One extra signature (`purpose: "app_fee"`). Deposit wallet or Safe: signed before the order on 99% of the quoted notional, submitted after the fill. Proxy wallet: after the fill, on the **realized** notional | Under 99% filled, the signed amount is discarded and one exact signature is requested after the fill | | Sell (Polymarket, Hyperliquid) | Signed before the sell on worst-case proceeds (shares × floor price × 99%), submitted after the sell settles | If the sell fills below that, the signed amount is discarded and one exact signature is requested after the fill; declining it leaves the fee uncollected | 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](#not-supported-with-the-exact-code)). 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. | Venue | Self-custody | Prompts, in order | Approvals | | - | - | - | - | | Hyperliquid | Yes | `eip712` builder-fee approval (once), then the `hl_l1_action` order | None on-chain | | Polymarket | Yes | Funding and setup batch, then the `eip712` order pair | Installed by the setup batch, as a signature | | Limitless | Yes, market fills only. Self-custody limit orders are refused, and positions AGG did not create are not offered for sale. Some older negRisk markets can fail at the venue (see below). | `transaction` approvals, then `personal_sign` registration (once, only while the wallet has no Limitless profile), then the `eip712` order (domain `Limitless CTF Exchange`) | Buy: an unlimited USDC `approve` to the exchange. Sell: CTF `setApprovalForAll` to the exchange, plus one for the adapter on a negRisk market. Each once. | | Predict | Yes, market fills only, with the same limits as Limitless. A buy must be at least \$1. | `transaction` approvals, then `personal_sign` over Predict's auth message (every fill), then the `eip712` order | An unlimited USDT `approve` to the exchange, CTF `setApprovalForAll` to the exchange, and one for the neg-risk adapter where the market needs it. Each once, only when missing. | | Myriad | Yes, market fills only, with the same limits. A wallet delegated on BNB is revoked first (see below). | `transaction` approvals, then `personal_sign` over Myriad's sign-in message (every fill), then the `eip712` order (domain `MyriadCTFExchange`) | An unlimited USD1 `approve` to the exchange (buys) and CTF `setApprovalForAll` to the exchange. Each once, only when missing. | | TipRun | Yes, market fills only, with the same limits as Limitless. Funded from Arbitrum USDC in the wallet (bridged in when needed): a buy deposits only the shortfall into the wallet's TipRun account, since cash already in that account is used first. Partners cannot allocate TipRun account cash as a separate funding source. Two fills running at once on the same wallet may both count on the same account cash. The later one then usually fails before its order is placed (credited cash below the order requirement), after its own shortfall deposit has already landed: that money is not lost, it stays as cash in the wallet's TipRun account. An existing TipRun account is adopted; a wallet with more than one is refused (see below), including a wallet that later gains a second account, for buys and sells alike: trade or withdraw it in TipRun's own app. Withdrawals are not offered: withdraw in TipRun's own app. | `eip712` `RegisterAccount` (once, only while the wallet has no TipRun account), then an `eip712` `Permit` for each buy that deposits, then the `eip712` `LimitOrder` | None on-chain. The deposit is a Permit signature. | | Kalshi | Yes. Funded from the user's own Kalshi cash and signed with their own Kalshi API key ([Venue keys](/recipes/venue-keys)), with no wallet prompt. Without a stored key the fill is refused as `quote_insufficient_balance`. With a partner fee or user-paid referral, a Kalshi-only fill is refused with `quote_self_custody_app_fee`; routes with an EVM leg collect the fee there. | None | None | | Novig, ProphetX | Only on the app's `direct` backend, the same model as Kalshi: the leg trades the user's own connected Novig or ProphetX account and cash, with no wallet prompt. A `clearinghouse` leg is refused as `quote_self_custody_unsupported_venue`. A linked Solana wallet can still fund the EVM venues above; see [Solana](#solana). With a partner fee or user-paid referral, a direct-only fill is refused with `quote_self_custody_app_fee`. | None | None | | Every other venue | No. `quote_self_custody_unsupported_venue` at fill. | — | — | 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](#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](/concepts/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, 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](/recipes/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](/recipes/building-market-views#deep-cost-estimate-deepestimatetrue). 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 }`. | `code` | Meaning | What to do | | - | - | - | | `quote_self_custody_unsupported_venue` | The route touches a venue without self-custody support (anything except Polymarket, Hyperliquid, Kalshi, Limitless, Predict, Myriad, TipRun, and Novig or ProphetX on the `direct` backend). | Re-quote without `signingAddress`, or restrict `allowedVenues`. | | `quote_self_custody_app_fee` | The trade carries an app fee or user-paid referral that cannot be collected from this wallet. The message names why: `disabled` (self-custody fee collection is not enabled), `no_fee_recipient` (the app charges a fee but has no address to receive it), `user_broadcast` (the fee rides the bridge from the user's EVM wallet or from Solana, which needs `approveMode: "sponsored"`, or the field omitted), `deposit_wallet_funded`, `multi_leg`, or `non_evm_source`. A referral-only fill never hits `no_fee_recipient`. | Collection works for a buy funded from one EVM wallet with `approveMode: "sponsored"` or the field omitted (the batch prompt carries `purpose: "fee"` or `"funding_and_fee"`), and for a Solana-funded buy in the same modes, where enabled. A fee paid from the Polymarket deposit wallet (`purpose: "app_fee"`) or from a HyperCore balance works in either approve mode, where enabled. Otherwise re-quote without `signingAddress`, or clear the fee. | | `quote_self_custody_redeem_unsupported` | The quote settles by redeeming a resolved position on a venue without self-custody redeem (anything except Polymarket, Limitless, Predict, Myriad), or self-custody redeem is not enabled. | Re-quote without `signingAddress`. | | `quote_self_custody_limitless_foreign_profile` | The route lands on Limitless and the wallet already has its own Limitless account, which partner apps cannot trade for. | Trade it on limitless.exchange, use another wallet, or re-quote without `signingAddress`. | | `quote_self_custody_limitless_smart_wallet` | The route lands on Limitless and the wallet's Limitless account uses smart-wallet trading, which rejects self-signed orders. | Switch the account to wallet (EOA) trading on limitless.exchange, then re-quote. | | `quote_self_custody_tiprun_ambiguous_account` | The route lands on TipRun and the wallet owns more than one TipRun account. | Use a wallet with a single TipRun account, or re-quote without `signingAddress`. | | `quote_unfillable` | The quote was priced for a different signing wallet or `fundingAddresses` set, or has no route for these sources. The `message` says which. | Re-quote for the wallets you sign with. | 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`. ```ts theme={null} await client.redeemSelfCustody({ venueMarketOutcomeId, signingAddress }); // A signer that lives outside the client (a React provider) can be passed per call: await client.redeemSelfCustody({ venueMarketOutcomeId, signingAddress }, { signer }); ``` 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](/recipes/self-custody-signing#limit-order-cancel-signatures). > [!WARNING] > > 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](#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: | Value | What users see | | - | - | | `"managed"` (default) | The AGG balance only. No "Trade from" selector and no wallet signing. | | `"both"` | A "Trade from" selector on `PlaceOrder` with the AGG balance and each linked EVM wallet. A wallet trade is signed in the wallet. | | `"self-custody"` | Linked wallets only. `PlaceOrder` trades from the connected linked EVM wallet, or the first one linked. The AGG balance, Deposit, and Withdraw are hidden in `PlaceOrder`, the connect button, and the profile page. | ```tsx theme={null} {children} ``` 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. ## Related > **[Self-custody signing reference](/recipes/self-custody-signing)** > > Request types, the signer, prompt counts, and the raw signing loop. > **[Execute quote](/api-reference/trading/execute-quote)** > > Request and response schema for `POST /execution/fill`. > **[Submit signatures](/api-reference/trading/submit-signatures-for-a-pending-self-custody-fill)** > > The endpoint behind the signing loop. > **[Account linking](/recipes/account-linking)** > > Link the EVM wallet self-custody needs. # Self-Custody Signing Reference Source: https://docs.agg.market/recipes/self-custody-signing Signature request types, a complete signer, how many prompts a user sees, and the raw signing loop This page is the reference for the signing half of [Self-custody trading](/recipes/self-custody): what each request asks the wallet to do, how to answer it, and what errors mean. ## The request Each entry in `pendingSignatures[]` looks like this: ```ts theme={null} { stepId: string; // send it back verbatim type: SignatureRequestType; venue: string; // who the request is for; switch on `type`, not this signerAddress: string; // the wallet that must sign or send chainId?: number; // often absent; read the chain from the payload payload: unknown; // sign or send it verbatim expiresAt: string; // ISO-8601; always read it purpose?: string; // funding, funding_and_fee, fee, app_fee, referral_payout, or "hl_referral" optional?: boolean; // true on purpose: "hl_referral"; the trade proceeds if you skip it } ``` ## What each type asks for | `type` | What to call | Submit | | - | - | - | | `eip712` | `signTypedData(payload)` | `signature` | | `hl_l1_action` | `signTypedData(payload)`, EIP-712 for a Hyperliquid action | `signature` | | `personal_sign` | `signMessage({ message: payload })` when `payload` is a string, or `signMessage({ message: { raw } })` when it is `{ raw }`. Used for a Relay EIP-191 permit, the Limitless registration message, the Predict auth message, and the Myriad sign-in. | `signature` | | `eip7702_authorization` | `signAuthorization(payload)` over `{ contractAddress, chainId, nonce }`, then serialize to a 65-byte signature | `signature` | | `safe_tx` | `signMessage({ message: { raw: payload.safeTxHash } })`, a `personal_sign` over the raw 32-byte SafeTx hash. Used for a legacy Polymarket Safe. | `signature` | | `transaction` | Switch the wallet to `payload.chainId`, then send `{ to, value, data }` from `signerAddress` | `txHash` | | `solana_transaction` | Sign the transaction in `payload.transactionBase64` with the Solana wallet, without sending it. Issued only when a linked Solana wallet is named in `fundingAddresses`; see [Solana](/recipes/self-custody#solana). | `signature` (`0x` + 128 hex, the ed25519 signature) | Send exactly one of `signature` and `txHash` per step. Signatures are `0x`-prefixed hex. A fill funded entirely from a legacy Polymarket Safe asks for one `safe_tx` signature and never an approve or an EIP-7702 authorization. A fill funded from a Safe plus another wallet on the same chain also asks for the usual steps for that other wallet. ## A complete signer `fillSelfCustody` calls your signer once per request. Return a hex signature, or `{ txHash }` for a `transaction`. ```ts theme={null} import { serializeSignature } from "viem"; import type { SignerFn } from "@agg-build/sdk"; const mySigner: SignerFn = async (req) => { switch (req.type) { case "eip712": case "hl_l1_action": return walletClient.signTypedData(req.payload as never); case "personal_sign": { const p = req.payload as string | { raw: `0x${string}` }; return walletClient.signMessage({ message: typeof p === "string" ? p : { raw: p.raw } }); } case "safe_tx": { const { safeTxHash } = req.payload as { safeTxHash: `0x${string}` }; return walletClient.signMessage({ message: { raw: safeTxHash } }); } case "eip7702_authorization": { const auth = await walletClient.signAuthorization(req.payload as never); // viem types `yParity` and `v` as optional. Derive rather than default: // a wrong parity recovers to another address and the server rejects it. const yParity = auth.yParity ?? (auth.v === undefined ? undefined : Number(auth.v) - 27); if (yParity !== 0 && yParity !== 1) throw new Error("authorization has no usable parity"); return serializeSignature({ r: auth.r, s: auth.s, yParity }); } case "transaction": { const tx = req.payload as { chainId: string; to: string; value: string; data: string }; // Broadcast on the request's chain, not whichever one the wallet is on. await walletClient.switchChain({ id: Number(tx.chainId) }); // Return the hash right away. Do NOT wait for the receipt. return { txHash: await walletClient.sendTransaction({ to: tx.to as `0x${string}`, value: BigInt(tx.value), // decimal wei string; "0" for an approve data: tx.data as `0x${string}`, }), }; } default: throw new Error(`unsupported signature request: ${req.type}`); } }; ``` > [!WARNING] > > **Throwing from your signer is not a rejection.** `fillSelfCustody` stops at once and drops the > signatures it had collected for that batch, but the server has no reject call: the request > simply expires and the fill fails. If you know a wallet cannot produce a raw-digest EIP-7702 > authorization, fill with `approveMode: "user_broadcast"` instead. **When `transaction` reaches the trader.** In two cases. On a bridge: the source token needs an on-chain approve rather than a permit signature, **and** the fill set `approveMode: "user_broadcast"`. Omitting `approveMode`, or sending `approveMode: "sponsored"`, asks for that same approval as `eip7702_authorization`: a raw digest, not EIP-712 and not EIP-191. A permit stays `eip712`. That split matches [Choosing an approve mode](/recipes/self-custody#choosing-an-approve-mode). On a Limitless, Predict or Myriad fill: the venue's own approvals (see [Venues](/recipes/self-custody#venues)) are always sent by the wallet, whatever the approve mode, and always arrive before the order signature. Venue orders, the Polymarket wrap, and the Hyperliquid builder-fee approval are always signatures. The other `transaction` you may see is an app-paid referral payout addressed to your payer wallet. Check `signerAddress` before you prompt. See [Fees](/concepts/fees#app-paid). After a `transaction`, return the hash without waiting. AGG watches for the mined receipt and does not continue until it matches the request. ## Timeouts, and what expiry means Every request carries `expiresAt`. **Read it; never hard-code a deadline.** Steps in one fill can have different deadlines, and the shortest is tight: | Request | Deadline | | - | - | | Hyperliquid order | 2 minutes | | Hyperliquid builder-fee approval | 90 seconds | | Polymarket order pair | 90 seconds | | Limitless registration and order | 1 minute | | Predict auth and order | 1 minute | | Myriad sign-in and order | 1 minute | | TipRun registration, deposit permit and order | 1 minute | | Bridge signatures | 2 minutes | | Polymarket setup | 5 minutes | | Polymarket fund transfer | 2 minutes | | Polymarket fee after the fill (`app_fee`) | 30 minutes | | `transaction` | 15 minutes | When a fill dies, `GET /execution/status` reports `overallState` `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 showing as expired, so an empty list never means "done". Only `terminal: true` does. Submitting a signature for an expired request returns `400`. There is no recovery: quote again. A user has one live execution at a time. If a request expires instead of being answered, a new fill does not start until the abandoned one has finished failing, so ask the user to complete or abandon deliberately. ## How many wallet prompts the user sees Requests parked together arrive in one batch, but the user approves each one. The total is the sum of three parts: the venue order, a Polymarket funding step, and the funding transfers. **Venue order** | Situation | Prompts | | - | - | | Hyperliquid order | 1 | | Hyperliquid builder-fee approval | 1, until granted for that wallet. A saved builder address is approved the same way. See [Builder codes](/recipes/builder-codes). | | Polymarket order | 2, always a pair, buy or sell | | Limitless order | 1, plus 1 `personal_sign` registration the first time that wallet trades on Limitless | | Predict order | 2: a `personal_sign` auth message, then the `eip712` order | | Myriad order | 2: a `personal_sign` sign-in, then the `eip712` order. One more `eip7702_authorization` (delegate `0x0`) first when the wallet is EIP-7702 delegated on BNB. | | TipRun order | 1 `eip712` `LimitOrder`, plus 1 `eip712` `Permit` when the buy deposits (only the shortfall the TipRun account cash does not cover), plus 1 `eip712` `RegisterAccount` the first time a wallet with no TipRun account trades. An existing TipRun account is adopted with no prompt. | | Limitless, Predict or Myriad approvals | 1 `transaction` per missing approval, once per wallet and exchange contract. See [Venues](/recipes/self-custody#venues). | **Polymarket funding step** | Situation | Prompts | | - | - | | Buy with USDC.e waiting in the deposit wallet and not enough settled pUSD | **1, after every bridge** | | Buy already covered by settled pUSD in the deposit wallet | 0 | | First trade on a new deposit wallet, buy or sell | 1. Sets up approvals; a buy's wrap rides in the same signature. | | Any later sell | 0 | > [!WARNING] > > The wrap prompt is **not** one-time. Funding arrives as USDC.e, which must be wrapped before a > buy settles, so a returning user sees it on every freshly funded buy. In the rows below, "default mode" means `approveMode` omitted or `approveMode: "sponsored"`: the extra approval is `eip7702_authorization`, not EIP-712. `user_broadcast` is `approveMode: "user_broadcast"`. **Funding transfers, per funding source.** A route funded from two chains pays this twice. | Situation | Prompts | | - | - | | Hyperliquid order paid from a Hyperliquid balance | 0 | | Polymarket order paid from USDC.e or pUSD already in the deposit wallet | 0, plus 1 when the buy carries a fee (`purpose: "app_fee"`): before the order, or after the fill from a proxy wallet or when under 99% fills | | Polymarket order paid from Polygon funds on the user's own address | 1 in the default mode, 2 if the wallet has not signed an EIP-7702 authorization yet. Refused under `user_broadcast`. | | Cross-chain from native USDC (signature only) | 1 in either mode. With a partner fee or user-paid referral it becomes approve plus deposit so the fee rides that batch: 1, or 2 before the first authorization. | | Cross-chain from a token needing an approval, default mode, authorization already signed | 2 | | Cross-chain from a token needing an approval, default mode, first time on that chain | 3 | | Cross-chain from a token needing an approval, `user_broadcast` | 2: one transaction, one signature, on **every** bridge | | Paid from a Hyperliquid balance for another venue | 2 | | Hyperliquid order paid only from pUSD in the Polymarket deposit wallet | 1 | | Hyperliquid order paid from the deposit wallet together with another source | 1 to move the pUSD to the EOA, plus the cross-chain row for that token | With a partner fee on an EVM-funded buy, the funding batch also carries the fee transfers. A user-paid referral is one more transfer in the same batch. Worked examples: a Hyperliquid buy from an existing Hyperliquid balance is **1** prompt (2 while the builder approval is outstanding). A Polymarket buy bridged from native USDC is **4** (one funding transfer, one wrap, two order signatures), and stays 4 on repeat. The same buy from a token needing an approval, before the first authorization, is **6**. A Polymarket sell is **2**, or **3** the first time that deposit wallet trades. ## Driving the loop yourself Without `fillSelfCustody`, run this loop: 1. `POST /execution/fill` with `quoteId` and `signingAddress`. The response never carries `pendingSignatures`, even for self-custody. 2. Poll `GET /execution/status?quoteId=`, waiting `pollAfterMs` between calls. 3. For each new `pendingSignatures[]` entry, have the wallet named by `signerAddress` sign or send the payload. 4. Submit the results with the call below. Up to 32 entries. Send `signature` or `txHash`, not both. The response has the same shape as the fill response. 5. Repeat from step 2 until `terminal` is `true`. ```ts SDK theme={null} await client.submitFillSignatures(quoteId, [{ stepId, signature }]); // A transaction step is { stepId, txHash } and no signature. ``` ```bash cURL theme={null} curl -s -X POST "$API/execution/fill/$QUOTE_ID/signatures" \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"signatures":[{"stepId":"'"$STEP_ID"'","signature":"'"$SIGNATURE"'"}]}' ``` [Submit signatures for a pending self-custody fill](/api-reference/trading/submit-signatures-for-a-pending-self-custody-fill). Errors from the signatures endpoint: | Status | Message | Meaning | | - | - | - | | `403` | `no such fill for this account` | Unknown quote, or not this user's. The two look the same on purpose. | | `404` | `no pending request for step …` | The `stepId` does not match a waiting request. | | `400` | `request for step … has expired — re-quote and retry` | The request is dead. Start over. | | `400` | `signature for step … is not 0x-prefixed hex` | Malformed signature. | | `400` | `txHash for step … is not a 0x-prefixed 32-byte hash` | Malformed transaction hash. | | `400` | `signature was signed by 0x…, expected 0x…` | The wrong key signed. Both addresses are lowercased. | | `400` | ``step … is a signature request — return `signature`, not `txHash` `` | Wrong field for the step's type. The reverse message names a transaction step. | Resubmitting a step that already succeeded is ignored, so retrying a batch after a network error is safe. Do not send the same `stepId` twice in one body: duplicates collapse to the last entry, so one bad entry discards the good one and the request fails. `purpose: "hl_referral"` with `optional: true` is the one-time Hyperliquid referral on the user's account. If the signer skips it or it expires, submit the rest. The trade still proceeds. See [Builder codes](/recipes/builder-codes). ## Limit-order cancel signatures Managed cancel without a signature is on [Limit orders](/recipes/limit-orders). A self-custody cancel is the same `POST /execution/orders/cancel`, plus `cancelSignature`. [Cancel an order](/api-reference/trading/cancel-an-order). AGG does not store the Polymarket L2 credentials it derives from a cancel, and it never starts or retries a self-custody cancel on its own. A stale read after you submit returns `cancel_pending`. Keep polling until the order is `cancelled`, or `filled` / `partial_fill` if a fill won the race. ### Polymarket Sign Polymarket's EIP-712 `ClobAuth` with the order's signing wallet. Domain `{ name: "ClobAuthDomain", version: "1", chainId: 137 }`. Primary type `ClobAuth` with `address` (address), `timestamp` (string), `nonce` (uint256), and `message` (string). `address` is the signing wallet. `timestamp` is the current Unix time in seconds, as a decimal string. `nonce` is `0`. `message` is `This message attests that I control the given wallet`. ```ts theme={null} await client.cancelManagedOrder(orderId, { cancelSignature: { signature, timestamp }, // timestamp is Unix seconds as a number }); // Same signature, for a Polymarket order placed outside AGG. await client.cancelVenueOrder({ venue: "polymarket", venueOrderId, signerAddress: linkedWallet, cancelSignature: { signature, timestamp }, }); ``` `signerAddress` must be linked to the signed-in user and must be the wallet that produced `cancelSignature`. An address that is not linked returns `400`. AGG reads the venue order first. If it is not open and owned by that wallet, the call returns `409`. That covers not found, already filled, already cancelled, and not yours. A success can still come back with an empty `orderIds` when the venue cancelled the order and AGG could not record it. The cancel on the venue still happened. Fills that happened before the cancel are not recorded. This does not work for a polymarket.com email or Magic wallet: that flow trades from a different wallet than the one linked here. If `venueOrderId` is an order AGG placed, it is cancelled the same way as `cancelManagedOrder`. ### Hyperliquid Build one `cancel` action with the venue's numeric asset id and the order's `venueOrderId`. Sign it with the EOA or its currently approved `agg` agent. Use a fresh millisecond nonce. The signature is `{ r, s, v }`. ```ts theme={null} import { signL1Action } from "@nktkas/hyperliquid/signing"; const action = { type: "cancel" as const, cancels: [{ a: assetId, o: Number(venueOrderId) }], }; const nonce = Date.now(); const signature = await signL1Action({ wallet: clientHeldSigner, action, nonce, isTestnet, }); await client.cancelManagedOrder(orderId, { cancelSignature: { venue: "hyperliquid", action, nonce, signature }, }); await client.cancelVenueOrder({ venue: "hyperliquid", venueOrderId, signerAddress: linkedWallet, cancelSignature: { venue: "hyperliquid", action, nonce, signature }, }); ``` The API checks ownership, the exact asset and order, and the signer. An agent must still be approved when cancelling. A rejected request can be retried with a fresh signature. An order placed outside AGG through the user's own agent, not the approved AGG agent, must be cancelled with a signature from the wallet itself. Only the wallet or its approved AGG agent is accepted. Open self-custody Hyperliquid orders are not cancelled by AGG. The client signs the cancellation. Native venue termination is still reconciled. # Settlement Key Differences Source: https://docs.agg.market/recipes/settlement-key-differences Surface how each venue settles a matched market — resolution source, timing, and edge cases — on the event detail The same prediction lives on more than one venue, but the venues don't always settle it the same way. One resolves "US presidential election" on the **AP call**; another waits for **official certification** weeks later. One voids a cancelled match; another resolves it 50‑50. Those differences are real settlement risk — and historically a trader had to read three sets of rules to find them. **Settlement Key Differences** does that reading for you. For any matched cross‑venue event, AGG returns a structured, per‑venue breakdown of *how the settlements differ*, plus each venue's full rules — so you can render a "Settlement" panel that tells a user exactly what they're trading before they trade it. It's returned **automatically on the single event‑detail endpoint** — no extra parameter, no extra call. ## What you get `GET /venue-events/{id}` adds `settlementDiff` on the event. `differences[]` carries `type`, `title`, `perVenue`, and `summary`. A venue is omitted from `perVenue` when its rules do not ground a value. The full object is on [Get venue event](/api-reference/markets/get-venue-event). `settlementDiffStatus` says what the diff means: | `settlementDiffStatus` | `settlementDiff` | Meaning | | - | - | - | | `computed` | object | Compared. `differences: []` means no key differences were found. | | `pending` | `null` | The event is matched, but the comparison has not run yet. | | `not_applicable` | `null` | No comparison: the event is in no cross-venue cluster, every venue's event has ended, or the differences only concern venues your app excludes. | `computed` with `differences: []` is a comparison of the venues' rules, not a guarantee that they settle the same way. Treat `pending` as unknown, not as equivalent. `type` is an open enum. Known values are `resolution_source`, `resolution_timing`, `scope`, `threshold`, `start_date`, `end_date`, `edge_cases`, and `wording`, with `other` as the catch-all. Map known types to an icon and use a generic icon for anything else. ## Fetch it ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const event = await client.getVenueEventById("evt_123"); if (event.settlementDiff) { // render the "Key differences" cards from event.settlementDiff.differences } ``` ```bash cURL theme={null} curl "https://api.agg.market/venue-events/evt_123" \ -H "x-app-id: YOUR_APP_ID" ``` [Get venue event](/api-reference/markets/get-venue-event). > [!NOTE] > > Settlement data is returned on the **single event‑detail** endpoint only — it is intentionally > **not** included in the `/venue-events` listing, where running a per‑event settlement lookup for > every row would be wasteful. Fetch it when a user opens an event. ## Render it The fields map one‑to‑one to a settlement panel. Render it with [Settlement](/components/trading/settlement), or from the fields below. 1. **Key differences** — one card per `settlementDiff.differences[]` entry: the `title` as the heading, an icon chosen from `type`, and one row per venue from `perVenue` (key = venue, value = the short per‑venue value). The card count is `differences.length`. 2. **Empty** — when `settlementDiff` is `null` or `differences` is `[]`, hide the "Key differences" block. `settlementDiffStatus` tells "no differences found" (`computed`) apart from "not compared yet" (`pending`) and "no comparison" (`not_applicable`). ## How it's built The differences are produced by a small **hybrid engine**, computed **once per matched cross‑venue cluster** and persisted — it runs in the background, completely off the trade/quote path, so reading them never adds latency to execution. * **Deterministic core (no model):** the structured facts AGG already ingests — resolution dates, resolution sources, thresholds, market scope — are compared directly across venues. This is exact and grounds the timing/source/threshold/scope differences with zero ambiguity. * **Language model gap‑fill:** for the nuances that only live in prose (edge cases like recounts, walkovers, void conditions, or a resolution source named only in the rules text), a cost‑efficient model reads each venue's rules and fills the remaining differences — and is **only** invoked when a venue's settlement genuinely isn't captured structurally. Both paths emit the same structure, and a difference is surfaced only when venues actually diverge and the value can be grounded in a venue's own rules — AGG omits a venue rather than guess. The result is cached per cluster and refreshed as venues update their rules. # Building a Sports Market Detail Page Source: https://docs.agg.market/recipes/sports-market-detail Compose the discovery, orderbook, and chart endpoints into a cross-venue game detail page A sports game's markets are spread across companion events (moneyline, spreads, totals, props) and venues. The game-wide fetch is an `aggKey` filter (for example `agg_fifwc_bih_che_260618`). Build the detail page with three steps and no client-side matching. Which id to store is [Ids](/concepts/ids). > [!NOTE] > > **This page needs exactly one piece of string parsing: the spread/total ladder family key.** > Everything else — tabs, labels, ordering, periods, lines, prices, cross-venue identity — comes from > typed fields (`marketGroup`, `sportsMarketType`, `period`, `lineValue`, `sectionRank`, > `matchedVenueMarkets[]`). Treat `aggKey` as an opaque filter value everywhere else; see > [Do you need to parse it?](/recipes/agg-key#do-you-need-to-parse-it). ## Listing the games (one tile per game) `GET /venue-events?grouped=true` returns **one tile per game**: a venue's companion events (`… - More Markets`, `… - Exact Score`, `… : Spread`) fold under their base, and cross-venue siblings collapse to the cluster anchor. Tap a tile, take its `id`, and load the detail page below. Each tile carries the game `aggKey` plus two counts: `groupMarketCount` covers that **venue's** companion group, `marketCount` is cluster-scoped on the by-id response. Neither counts the whole game across companions *and* venues — only the `aggKey` query in step 2 does. > [!NOTE] > > `/venue-events` takes **event-level** filters only (`categoryIds`, `status`, `venue`, `aggKey`, > `grouped`, …). Market-level filters — `marketGroup`, `sportsMarketType`, `period`, `marketCategory` > — live on `/venue-markets`; unknown query params on `/venue-events` are ignored, not rejected, so > `/venue-events?marketGroup=…` silently returns every event in the category. > [!WARNING] > > `grouped` is **opt-in** — only pass `grouped=true` once you have the game-detail page below, > because grouping hides the companion events from the listing and the detail page is what > re-surfaces their markets (spreads, exact score, props) via `aggKey`. Omit `grouped` (the > default) and companion events list as separate tiles. ## 1. Resolve the game (any member event → the same page) `GET /venue-events/:id` → read `structureType` (`sport` → render the game page) and `aggKey` (the game handle). > [!WARNING] > > **The page is keyed on the game (`aggKey`), not on the event you opened.** A game's companion > events (`… - Halftime Result`, `… - Exact Score`, `… : Spread`) each have their own `id` but share > the same `aggKey`. Opening **any** of them must render the **same** game page. Derive the tabs and > markets below from the `aggKey` — NOT from the opened event's own `venueMarkets`. If you key off the > opened event, the base event shows only game lines and the "Halftime Result" companion shows only > halves, even though both are the same game. ## 2. Build the tabs from the game `aggKey` Fetch the game's markets by `aggKey` and group them client-side by `marketGroup`: `GET /venue-markets?aggKey={gameAggKey}&status=open&limit=100&cursor=…` * `aggKey` is **game-scoped**: you get every market of the game across companion events + venues (moneyline, spreads, totals, props), deduped to one row per logical market (cross-venue siblings on `matchedVenueMarkets[]`). * **The visible tab set is the `marketGroup`s present in this result** — not the groups of the opened event. Hide groups with no markets. Paginate large games with `cursor`/`limit`. * To load tabs lazily instead of all at once, add a `marketGroup` (+ `period`) filter per tab — but the **visible tab set still comes from the game**, so probe which groups exist up front (one unfiltered `aggKey` page is usually enough; a game is dozens of markets, not hundreds). * Other enums for sub-grouping/ordering within a tab: `sportsMarketType` (`moneyline`/`spread`/`total`/`prop`/`to_advance`/`completion`/`other`), `period` (see the full vocabulary below — `full`/`1h`/`2h`/`q1`..`q4`/`set_N`/`map_N`/`inning_N`/…), `lineValue` (number), `sectionRank` (order). ### Game Lines moneyline — source it from the game **root** The aggKey set carries moneyline rows from every sibling venue-event, so the raw set can show duplicate "Draw"s or drop an outcome. For the match-result N-way, take the moneyline markets from the opened game event instead: `GET /venue-markets?venueEventId={openedEventId}&marketGroup=game_lines` Keep the spread/total ladders from the full aggKey set. Listing endpoints already surface group roots (`?grouped=true`), so the event you open IS the root in the normal flow. > [!NOTE] > > `?venueEventId=` is **cluster-scoped by default** (`cluster=true`): every venue's markets for that > event, matched siblings folded into one anchor row. That fold is what removes the duplicate > outcomes, so no client-side dedupe. Send `cluster=false` for one venue event's own markets, and pass > the same value to `GET /venue-events/{id}`, whose `marketCount` follows the flag — > [details](/recipes/matched-clusters#market-lists-preview-vs-full). > > The row you get back for a matched cluster is its **anchor** — the other venues' rows arrive on > `matchedVenueMarkets[]`. Matching *prefers* a member carrying the game `aggKey` as the anchor, but > does not guarantee one: an anchor's `aggKey` may still be `null`, so keep handling that case. > > It does **not** reach same-venue companions, which never share a cluster. The step 2 `aggKey` query > remains the only game-wide fetch. Which id to store is [Ids](/concepts/ids). ### Spread / total ladders — group by the `aggKey` line-family, NOT the question A spread or total is one card with a line selector (O/U 0.5 / 1.5 / 2.5 …), and the same line is offered by several venues. **Do not group these by `question` or `marketSubtype`** — each venue phrases and labels them differently for the *same* market (e.g. `"Argentina O/U 0.5"` / `"Will over 0.5 goals be scored?"` / `null`), which fragments one ladder into separate cards. Group by the **line-family key** on [aggKey](/recipes/agg-key#grouping-a-line-ladder), gated on `sportsMarketType`. The family drops the period token — take period from the `period` field and put it in your group key alongside the family. Within a family, union the venues per line. Order totals by `lineValue`; order spreads by `|lineValue|`, taking the side of the axis from the `m`/`p` token, since polymarket stores spreads negative and kalshi/novig/predict store them positive. > [!WARNING] > > Both failure modes here are silent — a wrong grouping, not an error. A bare `_\d+p\d+` strip no > longer matches spread keys (they read `_m19p5`), and splitting on a marker instead of the date can > find a **team** of that name. Full detail: > [Marker tokens are not unique](/recipes/agg-key#marker-tokens-are-not-unique). **Total subjects.** A match total (`…_tot_8p5`), a team total (`…_tot_team_arg_1p5`), corners (`…_tot_corners_9p5`), cards (`…_tot_cards_3p5`) and — for racket sports — total *sets* (`…_tot_sets_3p5`) each get their own family. The sets token matters: a tennis match publishes both a total-sets market (2.5–4.5) and a total-games market (36.5–40.5), and without a subject to separate them they land in one card whose selector mixes the two. > [!WARNING] > > **`venueMarketOutcomes[0]` is the team the key NAMES — the favourite on an `m` row and the > UNDERDOG on a `p` one.** Labelling index 0 as "favourite −X" unconditionally mislabels every > `p` row, and the mislabel is silent: the pill reads as the side that covers the spread while the > book underneath is the side receiving the points. Read the role off the leg token, never off the > outcome order. ### When a market has no `aggKey` — absent does not mean different `aggKey` is a **pricing** key. Every venue mints it fail-closed: if the derivation cannot *prove* two markets are the same bet, it emits nothing rather than risk a wrong merge. A meaningful share of open spread/total markets therefore carry `aggKey: null`, and a great many props carry none by design. So `aggKey` answers "can I route money between these?" — **not** "are these one card?" Treating a missing key as "a different market" is what produces the classic detail-page failure: `Spreads` twice, `Totals` twice, one card per venue per line. Give the no-key path a *structural* family (`period` + `marketGroup` + `sportsMarketType` + normalized `marketSubtype`) rather than falling back to the market id. ### Decomposed N-way markets — one card, one pill per outcome A venue that cannot list a three-way natively publishes **one bare Yes/No market per outcome**, with the subject in the question and usually no `aggKey`: ``` "Real Betis Balompié leading at halftime?" ← polymarket, Yes/No "… : Draw at halftime?" ← polymarket, Yes/No "Real Madrid CF leading at halftime?" ← polymarket, Yes/No ``` Render these as **one card with a pill per leg**, not one card per leg. The legs of one question share the venue's `marketSubtype`, and the token reconciles across venues once you strip a namespace prefix — polymarket's `soccer_halftime_result` and predict's `SPORTS_HALFTIME_RESULT` are the same question. Restrict the rule to per-question groups (`game_lines`, `period_lines`, `team_props`, `specials`, `completion`): `player_props` members are per-PLAYER, so a dozen markets sharing `baseball_player_hits` are a dozen different bets, not a dozen legs. > [!WARNING] > > **A ladder rung is not a leg.** Every rung of a line ladder is also its own Yes/No market with its > own question (`"Boston over 1.5 runs scored"`, `"…over 2.5…"`), which is shape-identical to a > decomposed leg. The difference is what the markets vary along: a leg is an **outcome** of one > question, a rung is a **line** of one market. `lineValue` is that flag — if any market in the group > has one, render a ladder with a line selector, not a pill per market. > [!NOTE] > > **`question` on a decomposed leg is the venue's own short label, not a stable identity.** > `/venue-markets` returns polymarket's leg as `Real Betis Balompié` and predict's identically-worded > leg as `BET`. Comparing prose across venues will not fold them; only `matchedVenueMarkets[]` proves > two legs are the same bet. ### A sanity check for any card: the pill invariant A card that draws one pill per market can never honestly show **more pills than the most legs a single venue publishes for that question** — a question has no more outcomes than the venue that enumerates all of them. Any excess is the same bet appearing twice under two venues' wordings. When you hit it and cannot fold the duplicates, **split the card by venue**: each venue does enumerate its own question completely, so one card per venue keeps every pill accurate and hides nothing. Two exceptions are not violations — a line ladder (the rail renders the rungs, not the pills), and a group anchored by a market whose outcomes are already team-titled (it states the whole question on its own, and the other markets are its decomposition). ### Tab → filter mapping * **Game Lines** — `marketGroup ∈ {game_lines, spreads, totals}` (moneyline from the root member, above) **plus `sportsMarketType=to_advance`** (see "Team to Advance" below — it carries `marketGroup=specials` but belongs here). * **Halves** — `marketGroup=period_lines`, `period ∈ {1h, 2h}` (basketball / american\_football also expose quarters `q1`–`q4`, hockey `period_1`–`period_3` — same `period_lines` group, sub-grouped by `period`). Do **NOT** also filter on `sportsMarketType`: half-result legs ("France leading at halftime?") are `sportsMarketType=other` and an AND'd type filter drops them. * **Exact Score** — `marketGroup=exact_score` * **Props** — `marketGroup ∈ {team_props, player_props, specials, completion}`, **excluding `sportsMarketType=to_advance`** (those render under Game Lines). * **Other** — `marketGroup=other` > [!NOTE] > > Exact-score and both-teams-to-score markets now carry their own `aggKey` (`…_cs_{teamA}{scoreA}_{teamB}{scoreB}` > and `…_btts`), so those tabs get cross-venue identity from the key and `matchedVenueMarkets[]` the > same way Game Lines does. Player props and individual strikes of a scalar ladder still return > `aggKey: null` — group those by `marketSubtype` and rely on the cluster for linkage. See the > [aggKey coverage table](/recipes/agg-key#what-it-converges). ### Team to Advance — a Game Lines market, above the moneyline Knockout games (World Cup, playoff series, etc.) carry a **"Team to Advance"** market: `sportsMarketType=to_advance`. It is a primary game line — venues render it **above** the moneyline — but its `marketGroup` is `specials`, so filter it in **by `sportsMarketType`, not `marketGroup`**: source `to_advance` from the game set alongside the moneyline and place it first in the Game Lines tab. For a venue whose *only* market on a game is the advance single (it lists nothing else), this is what makes the Game Lines tab non-empty. Some venues decompose it into per-team Yes/No legs (`…_adv_{team}`) while others list one 2-outcome single (`…_adv`, team-name pills). Group the whole family with the line-family key (below); anchor the card on the bare-`_adv` single when present. ### Prop tabs via `marketCategory` (deprecated — use `marketGroup` instead) > [!WARNING] > > `marketCategory` is **deprecated** in favor of `marketGroup` (see the Fields reference below). > It remains populated for back-compat — the soccer-only prop tabs (`corners`, `goals`, `assists`, > `shots`) still appear there — but new code should filter by `marketGroup`. Each market carries a normalized `marketCategory` — the subject grouping for the prop tabs, orthogonal to `sportsMarketType`: * `game_lines` — moneyline / spreads / totals (sub-group with `sportsMarketType` + `period`) * `exact_score`, `corners`, `goals`, `assists`, `shots` — the dedicated prop tabs (soccer only) * `other` — recognized sports markets without a dedicated tab; `null` for non-sports markets Load one prop tab the same way you load Game Lines: `GET /venue-markets?aggKey={gameAggKey}&marketCategory=corners&limit=50` As with every other enum, the API exposes the category; your UI owns the tab label. ## 3. Prices, order ticket, chart * Live prices: `GET /midpoints?venueMarketIds=…`. The market list carries no prices. The call is [Comparing venue prices](/recipes/comparing-venue-prices). * Order ticket: [Quotes](/concepts/quotes), keyed by `venueMarketOutcomeId`. * Price chart: `GET /charts/bars` with `venueMarketOutcomeId`. See [Charts](/api/market-data#charts). `GET /sports/live` returns the score, period, and clock for the venue event ids on the page. Ids the provider cannot score are listed in `missingEventIds`. The client splits ids into requests of 25. ```ts SDK theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "YOUR_APP_ID", }); const scores = await client.getSportsLive(["EVENT_ID"]); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/sports/live" \ -H "x-app-id: YOUR_APP_ID" \ --data-urlencode "eventIds=EVENT_ID" ``` [Get live sports scores](/api-reference/market-data/get-live-sports-scores). ## Fields reference The following fields are available on `/venue-events` and `/venue-markets` for sports content. ### `sport` (on VenueEvent) Normalized cross-venue sport classification. `null` for non-sports events. Closed vocabulary: | Value | Description | | - | - | | `soccer` | Association football | | `basketball` | Basketball (NBA, WNBA, college, international) | | `baseball` | Baseball (MLB, international) | | `american_football` | American football (NFL, college) | | `hockey` | Ice hockey (NHL, international) | | `tennis` | Tennis (ATP, WTA, Grand Slams) | | `cricket` | Cricket (Test, ODI, T20) | | `golf` | Golf (PGA, LIV, majors) | | `mma` | Mixed martial arts (UFC, Bellator, etc.) | | `boxing` | Boxing | | `motorsport` | Racing (F1, NASCAR, IndyCar, etc.) | | `esports` | Competitive gaming (CS2, Dota 2, LoL, Valorant, Honor of Kings, etc.) | | `table_tennis` | Table tennis / ping pong | | `rugby` | Rugby (union & league — Top 14, NRL, Six Nations, etc.) | | `volleyball` | Volleyball | | `pickleball` | Pickleball (MLP, PPA) | | `lacrosse` | Lacrosse (PLL, NLL) | | `aussie_rules` | Australian rules football (AFL/AFLW) | | `other_sport` | Any sport not in the list above | ### `groupParentId` (on VenueEvent) The id of this event's same-venue group base, or `null` when the event **is** the base (or is standalone). It is the explicit signal for "the event I opened is a companion, not the game" — the title suffix (`… - Exact Score`, `… : Spread`) is a hint, not a contract. Two consequences worth designing around: * **`grouped=true` returns only rows where this is `null`.** That is the definition of one tile per game. * **A companion's `matchedVenueEvents[]` is not the game's cluster.** Same-venue events never share a cluster, so a companion matches the *other venues' equivalent companions*. On a companion, `venueCount` can read `1` with an empty `matchedVenueEvents[]` while the game itself is live on four venues. Resolve the game by `aggKey`, or fetch `groupParentId` for the base. See [Companion events](/recipes/matched-clusters#companion-events-why-a-cluster-can-look-empty). Cross-venue pricing is unaffected either way: each market carries its own `matchedVenueMarkets[]` regardless of how its parent events cluster. ### `marketGroup` (on VenueMarket) Sport-aware FE grouping. Use this to build per-sport tab sets. **Supersedes `marketCategory`** for sports tabs (see note above). The `marketGroup` query parameter on `/venue-markets` accepts any value from this set. Closed vocabulary: | Value | Description | | - | - | | `game_lines` | Full-game moneyline / winner markets | | `spreads` | Handicap / point-spread markets | | `totals` | Over/under total-points or total-goals markets | | `period_lines` | Intra-game moneyline, spread, or total scoped to a period (quarter, half, inning, set, map) | | `exact_score` | Exact final score markets | | `team_props` | Team-level proposition markets (NRFI, first-5-innings winner, both-teams-to-score, etc.) | | `player_props` | Player-level proposition markets (points, rebounds, strikeouts, etc.) | | `specials` | Novelty / event-level specials (toss winner, method of victory, championship outright, **team to advance** — the last is `sportsMarketType=to_advance` and belongs in the Game Lines tab, not Props; see above) | | `completion` | "Will this match complete?" propositions (tennis, cricket) | | `other` | Sports markets that don't fit the groups above | ### `marketSubtype` (on VenueMarket) Lossless raw venue market type string preserved from discovery. Examples: `cricket_toss_winner`, `nrfi`, `tennis_set_winner`, `cs2_odd_even_total_kills`, `baseball_team_first_five_winner`, `soccer_halftime_result`, `map_handicap`. Use `marketSubtype` to label or disambiguate rows within a `marketGroup` (e.g. render the `nrfi` row with its proper label rather than a generic "Team Props" heading). `null` when the venue supplies no subtype. ### `period` (on VenueMarket) Temporal scope of the market. Expanded from the original set to cover all sports: | Tokens | Description | | - | - | | `full` | Full game / match | | `match` | Alias for full match (tennis, cricket) | | `series` | Multi-game series result | | `1h` / `2h` | First / second half (soccer, basketball, american\_football) | | `q1` / `q2` / `q3` / `q4` | Quarters (basketball, american\_football) | | `period_1` / `period_2` / `period_3` | Periods (hockey) | | `inning_1` … `inning_9` | Individual innings (baseball) | | `first_5_innings` | First-five-innings scope (baseball) | | `first_3_innings` / `first_7_innings` | First-three / first-seven-innings scope (baseball) | | `set_1` … `set_5` | Individual sets (tennis) | | `map_1` … `map_5` | Individual maps (esports — CS2, Dota 2, etc.) | | `game_1` … `game_N` | Individual games within a series (esports, some tennis) — `game_6` and beyond occur in long series | | `ot` | Overtime / extra time | ## Per-sport tab matrix Use this table as the starting point for building per-sport tab UIs. Render only the `marketGroup` values that appear for a given `sport`; hide empty tabs. > [!NOTE] > > Coverage reflects what venues currently list. `other` and `other_sport` are catch-alls. The set > is venue-driven and grows as new market types are listed. | Sport | marketGroups present | Notable subtypes / periods | | - | - | - | | `soccer` | `game_lines`, `spreads`, `totals`, `period_lines`, `exact_score`, `team_props`, `specials` | `soccer_halftime_result` (periods `1h`/`2h`), `soccer_exact_score`, `both_teams_to_score`; knockout `to_advance` (in `specials`, rendered under Game Lines); legacy corners/goals/assists/shots via `marketCategory` | | `basketball` | `game_lines`, `spreads`, `totals`, `period_lines`, `player_props` | quarters `q1`–`q4`; player points / rebounds / assists props | | `baseball` | `game_lines`, `spreads`, `totals`, `team_props`, `player_props` | `nrfi` (`inning_1`), `baseball_team_first_five_winner` (`first_5_innings`), strikeout / home-run player props | | `american_football` | `game_lines`, `spreads`, `totals`, `period_lines` | quarters `q1`–`q4`, halves `1h`/`2h` | | `hockey` | `game_lines`, `spreads`, `totals`, `period_lines` | `period_1`–`period_3` | | `tennis` | `game_lines`, `totals`, `spreads`, `completion` | `tennis_set_winner` (`set_1`–`set_5`), `tennis_first_set_totals` (`set_1`), `tennis_match_totals` (`match`), `tennis_completed_match` | | `cricket` | `game_lines`, `totals`, `team_props`, `player_props`, `specials`, `completion` | `cricket_toss_winner` (`specials`), `cricket_team_top_batter` / `cricket_most_sixes` (`player_props`), `cricket_completed_match` | | `golf` | `game_lines`, `specials` | Outright winner, matchup markets, MVP / championship specials | | `mma` | `game_lines`, `specials` | Fight winner, method-of-victory / round specials | | `boxing` | `game_lines`, `specials` | Fight winner, method / round specials | | `motorsport` | `game_lines`, `specials` | Race winner, podium specials | | `esports` | `game_lines`, `spreads`, `totals`, `player_props`, `specials` | `map_handicap` (`spreads`), `cs2_odd_even_total_kills` / `kill_over_under_game` (`player_props`), `first_blood_game` / `dota2_rampage` (`specials`); periods `map_1`–`map_5` / `game_1`–`game_5` | | `rugby` | `game_lines`, `totals`, `specials` | Match winner, draw, handicap; mostly moneyline today | | `volleyball` | `game_lines`, `totals`, `specials` | Match/set winner, set totals | | `pickleball` | `game_lines`, `specials` | Match winner; mostly moneyline today | | `lacrosse` | `game_lines`, `spreads`, `totals` | Moneyline / spread / total | | `aussie_rules` | `game_lines`, `spreads`, `totals` | Moneyline / line / total (AFL) | > [!NOTE] > > `sport` is resolved from the venue's league code first, then its **tags** (which carry a clean > sport name like "Rugby" or "Volleyball"), then the title. Sports a venue lists but that aren't yet > in the vocabulary fall to `other_sport` (still classified into `marketGroup`s) until added. ## Filter examples ### Baseball — team props for a specific game ``` GET /venue-markets?aggKey=agg_mlb_nyy_bos_260618&marketGroup=team_props ``` Returns the team-prop markets for that game: NRFI (`nrfi`, `period=inning_1`), first-5-innings winner (`baseball_team_first_five_winner`, `period=first_5_innings`), and any other team-level propositions available across venues. Each row's `matchedVenueMarkets[]` contains the cross-venue pairings so you can show the best price without a second request. ### Tennis — set and match totals ``` GET /venue-markets?aggKey=agg_atp_djokovic_sinner_260618&marketGroup=totals ``` Returns all over/under totals for the match: per-set totals (`period=set_1`, `period=set_2`, …) and full-match totals (`period=match`). Use `marketSubtype` to distinguish the rendering: `tennis_first_set_totals` gets a "First Set" label; `tennis_match_totals` gets "Match Total". # Venue Geo Availability Source: https://docs.agg.market/recipes/venue-geo-availability Check regional trading restrictions and quote warnings before asking a user to confirm a trade. Each venue AGG routes to restricts its own set of countries. A user in a restricted country can browse and quote normally but cannot fill on that venue. Detect ineligibility at quote time, not at fill time. Do not hard-code country lists. Read `warnings` on the quote, or the live policy endpoint below. ## Detecting ineligibility before the fill The quote includes a `warnings` array. Any venue in the route the user cannot fill on appears there, with the venue name and a human-readable reason. The quote does not remove blocked venues. It returns the route and flags the blocked legs, so you can gray them out or route around them before the user commits. Geo is enforced at fill. Match on the prefix `Venue not available in your region` to identify geo warnings. The trailing country code is omitted when the country cannot be determined. If you ignore the warnings, `POST /execution/fill` returns **403** with `Venue polymarket is not available in your region (US).` > [!NOTE] > > Geo eligibility is not exposed on the events or markets endpoints. Use quote warnings to check the proposed trade, and the policy endpoint below for country rules. ## Reading the lists `GET /app/venue-geo-policy` returns each venue's country policy. Authenticate with `x-app-id`. ```ts SDK theme={null} const { venues } = await client.getVenueGeoPolicy(); ``` ```bash cURL theme={null} curl -s https://api.agg.market/app/venue-geo-policy \ -H "x-app-id: $AGG_APP_ID" ``` Each entry has `blockedCountries`. Some venues also set `allowedCountries`. When `allowedCountries` is present, the venue can be traded only from those countries. An empty `blockedCountries` list is not unrestricted in that case. Ignore the empty block list. Novig is the example. It is available only in the United States, so it sets `allowedCountries` and leaves `blockedCountries` empty. A user outside those allowed countries cannot fill Novig. The United States is blocked on every other venue. That is AGG platform policy, not a copy of each venue's own terms. [Get the per-venue blocked-country lists (ISO 3166-1 alpha-2)](/api-reference/trading/get-the-per-venue-blocked-country-lists-iso-3166-1-alpha-2). > [!WARNING] > > This is the raw venue policy, not per-user eligibility. It does not account for the country resolved for this user, your app's disabled venues, or the fail-closed rule when the country is unknown. Do not gate a fill on it. `warnings` on the quote is the per-user answer. ## Country resolution Country comes from the request's edge-detected country unless your app is enrolled in [Trusted Proxy](/recipes/partner-trusted-proxy). Then it is resolved from the `x-agg-client-ip` you forward. If the country cannot be determined, no venues are available and every fill is rejected. This is fail-closed. A missing or bad proxy IP looks the same as a fully blocked user. VPN exits resolve to the VPN's exit country. # Venue keys Source: https://docs.agg.market/recipes/venue-keys Store the user's Kalshi API key, which every Kalshi trade uses, then read whether it is connected. This is the user's venue credential. Every Kalshi trade, managed or [self-custody](/recipes/self-custody), uses it. See [Kalshi](/venues/kalshi). It is not your app's `x-app-api-key`. Authenticate with `x-app-id` and `Authorization: Bearer`. The stored secret is never returned. The same ProphetX `accessKey` and `secretKey` can be connected in multiple apps. Connect them separately for the authenticated user in each app; each app must have ProphetX Direct enabled. An AGG `x-app-api-key` can also be reused across app IDs on app and user routes, including `/venue-keys`, without app membership checks. Keep the valid key unchanged and send the target `x-app-id` with a user bearer token issued for that target app. The key retains its original scope and rate limit. Admin routes continue to require a key issued for their target app. Keep shared keys secure. Reusing ProphetX credentials connects the same real venue account in each app, so its funds and venue limits are shared even though AGG stores each user's connection and orders separately. ```ts SDK theme={null} import { Venue } from "@agg-build/sdk"; await client.upsertVenueKey({ venue: Venue.kalshi, credentials: { keyId, privateKey }, }); const status = await client.getVenueKeyStatus(Venue.kalshi); // status.connected, status.validatedAt, status.kycStatus ``` ```bash cURL theme={null} curl -s -X PUT \ -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg keyId "$KEY_ID" --arg privateKey "$KALSHI_PEM" \ '{venue: "kalshi", credentials: {keyId: $keyId, privateKey: $privateKey}}')" \ "$API/venue-keys" curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/venue-keys/kalshi/status" ``` The cURL example uses `jq` to JSON-encode the PEM key, preserving its line breaks. Keep the key out of source control and logs. Full shapes on [Upsert a venue API key](/api-reference/users/upsert-a-venue-api-key) and [Get venue key connection status](/api-reference/users/get-venue-key-connection-status). List stored summaries with [List venue keys](/api-reference/users/list-venue-keys) (`client.listVenueKeys()`). Remove one with [Delete a venue key](/api-reference/users/delete-a-venue-key) (`client.deleteVenueKey(Venue.kalshi)`). # Webhook Event Reference Source: https://docs.agg.market/recipes/webhooks/event-reference Full payload schemas for every webhook event type All webhook events share the same envelope format: ```json theme={null} { "id": "evt_accounts.created:usr_abc123", "type": "accounts.created", "createdAt": "2026-04-09T18:15:03.000Z", "data": { ... } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Event id | | `type` | string | Event type (dot notation) | | `createdAt` | string | ISO 8601 timestamp | | `data` | object | Event-specific payload | *** ## accounts.created Fired when a new user is created in your app (first authentication). ```json theme={null} { "id": "evt_accounts.created:usr_abc123", "type": "accounts.created", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "userId": "usr_abc123", "appId": "app_xyz", "createdAt": "2026-04-09T18:15:03.000Z", "email": "user@example.com" } } ``` | Field | Type | Description | | - | - | - | | `userId` | string | The new user's ID | | `appId` | string | Your app's ID | | `createdAt` | string | When the user was created | | `email` | string \| null | The user's email address when AGG has one from magic link or OAuth, otherwise `null` | *** ## accounts.linked Fired when a user is linked to a partner external ID via the identity assertion flow. ```json theme={null} { "id": "evt_accounts.linked:usr_abc123:partner_42", "type": "accounts.linked", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "userId": "usr_abc123", "externalId": "partner_42", "linkedAt": "2026-04-09T18:15:03.000Z" } } ``` | Field | Type | Description | | - | - | - | | `userId` | string | The user's AGG ID | | `externalId` | string | Partner id (the HMAC `externalId`), not the venue `externalIdentifier`. See [Ids](/concepts/ids) | | `linkedAt` | string | When the link was created | *** ## wallets.ready Fired when a server-managed wallet is provisioned for a user (both EVM and Solana addresses are available). ```json theme={null} { "id": "evt_wallets.ready:usr_abc123", "type": "wallets.ready", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "userId": "usr_abc123", "evmAddress": "0x1234...abcd", "svmAddress": "ABC123...xyz", "createdAt": "2026-04-09T18:15:03.000Z" } } ``` | Field | Type | Description | | - | - | - | | `userId` | string | The user's ID | | `evmAddress` | string | EVM (Ethereum/Polygon) wallet address | | `svmAddress` | string | Solana wallet address | | `createdAt` | string | When the wallet was provisioned | *** ## trades.placed Fired when a user submits a trade. `orderId` in this event is the quote id (`quote_…`). Query the API for the breakdown. ```json theme={null} { "id": "evt_trades.placed:quote_abc123", "type": "trades.placed", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "orderId": "quote_abc123", "userId": "usr_abc123", "side": "yes", "timestamp": "2026-04-09T18:15:03.000Z", "externalId": "partner-abc" } } ``` | Field | Type | Description | | - | - | - | | `orderId` | string | Quote id. This example is `quote_…`, not a venue order id | | `userId` | string | The user who placed the trade | | `side` | string | Outcome side: `"yes"` or `"no"` | | `timestamp` | string | When the order was placed | | `externalId` | string \| null | Partner id (the HMAC `externalId`), not the venue `externalIdentifier`. See [Ids](/concepts/ids). `null` when the trade did not carry one | *** ## trades.filled Fired when a trade order reaches a terminal fill state. One event per venue-level order fill. ```json theme={null} { "id": "evt_trades.filled:ord_abc123", "type": "trades.filled", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "orderId": "ord_abc123", "userId": "usr_abc123", "status": "filled", "timestamp": "2026-04-09T18:15:03.000Z", "externalId": "partner-abc", "clientOrderId": null } } ``` | Field | Type | Description | | - | - | - | | `orderId` | string | The individual order ID (query API for fill details) | | `userId` | string | The user who placed the trade | | `status` | string | `"filled"` or `"partial_fill"` | | `timestamp` | string | When the fill was recorded | | `externalId` | string \| null | Partner id (the HMAC `externalId`), not the venue `externalIdentifier`. See [Ids](/concepts/ids). `null` when the trade did not carry one | | `clientOrderId` | string \| null | The `clientOrderId` a limit order was placed with. `null` on market orders and on limit orders placed without one. Limit orders carry no `externalId` | *** ## trades.fee\_reconciled Fired once per self-custody trade when its app fee reaches a final state. Self-custody fees need the user's second signature and an on-chain confirmation after the fill, so this lands a few seconds after `trades.filled`. Wait for this event instead of polling for the fee. It is sent for every self-custody trade, with `status: "none"` when no fee applied. ```json theme={null} { "id": "evt_trades.fee_reconciled:dag_abc123", "type": "trades.fee_reconciled", "createdAt": "2026-09-29T18:15:07.000Z", "data": { "dagRunId": "dag_abc123", "quoteId": "qt_abc123", "userId": "usr_abc123", "orders": [{ "orderId": "ord_abc123", "externalId": "partner-abc" }], "status": "confirmed", "feeDueRaw": "4693", "feeCollectedRaw": "4693", "appShareRaw": "3754", "platformShareRaw": "939", "chainId": "137", "tokenSymbol": "pUSD", "tokenAddress": "0xpusd", "appTxHash": "0xabc", "platformTxHash": null, "timestamp": "2026-09-29T18:15:07.000Z" } } ``` | Field | Type | Description | | - | - | - | | `dagRunId` | string | The trade execution run. The event id is keyed on it, so it arrives once per trade | | `quoteId` | string | The quote the trade executed | | `userId` | string | The user who placed the trade | | `orders` | array | Every order in the trade. `externalId` on each order is the partner id (the HMAC `externalId`), not the venue `externalIdentifier`. See [Ids](/concepts/ids). The fee is charged once across all of them | | `status` | string | `"confirmed"`, `"under_collected"`, `"failed"`, `"skipped_zero"`, or `"none"` when no fee applied | | `feeDueRaw` | string | Fee owed, in token-native atomic units | | `feeCollectedRaw` | string | Fee actually collected, in token-native atomic units | | `appShareRaw` | string | Your share of the fee | | `platformShareRaw` | string | AGG's share of the fee | | `chainId` | string \| null | Chain the fee was paid on (`"137"` Polygon, `"1337"` HyperCore) | | `tokenSymbol` | string \| null | Fee token symbol | | `tokenAddress` | string \| null | Fee token address | | `appTxHash` | string \| null | Transaction that paid your share | | `platformTxHash` | string \| null | Transaction that paid AGG's share, when paid separately | | `timestamp` | string | When the event was emitted | *** ## deposits.confirmed Fired when an on-chain token transfer into a user's managed wallet is confirmed. One event per (chain, transaction, user). Bridge legs and venue settlement payouts are excluded, so this only reflects money the user (or a third party) sent in. ```json theme={null} { "id": "evt_deposits.confirmed:137:0xabc123:usr_abc123", "type": "deposits.confirmed", "createdAt": "2026-09-24T18:15:03.000Z", "data": { "userId": "usr_abc123", "chainId": 137, "tokenSymbol": "USDC", "tokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "amountRaw": "100000000", "txHash": "0xabc123", "fromAddress": "0xsender", "timestamp": "2026-09-24T18:15:03.000Z" } } ``` | Field | Type | Description | | - | - | - | | `userId` | string | The user whose wallet received the deposit | | `chainId` | number | EVM chain ID, or the internal ID for Solana | | `tokenSymbol` | string | Token symbol, e.g. `"USDC"` | | `tokenAddress` | string | Token contract address or mint | | `amountRaw` | string | Amount in the token's atomic units (6 decimals for USDC) | | `txHash` | string | On-chain transaction hash | | `fromAddress` | string | Sending address, or empty when unknown | | `timestamp` | string | When the deposit was confirmed | *** ## markets.resolved Fired once per app when a prediction market resolves. Contains a summary — use the pull endpoint for the full list of affected users. ```json theme={null} { "id": "evt_markets.resolved:vm_123:app_xyz", "type": "markets.resolved", "createdAt": "2026-04-09T18:15:03.000Z", "data": { "venueMarketId": "vm_123", "externalIdentifier": "KXBTC-26APR09", "status": "resolved", "outcomes": [ { "label": "Yes", "winner": true, "payoutPerShare": 1, "refundAtCost": false }, { "label": "No", "winner": false, "payoutPerShare": 0, "refundAtCost": false } ], "affectedUsersCount": 1842, "detailsPath": "/apps/app_xyz/market-resolutions/vm_123/users" } } ``` | Field | Type | Description | | - | - | - | | `venueMarketId` | string | The resolved market ID | | `externalIdentifier` | string | Venue `externalIdentifier`, not the partner HMAC `externalId`. See [Ids](/concepts/ids) | | `status` | string | `"resolved"` or `"closed"` | | `outcomes` | array | Resolution outcome for each option: `label`, `winner`, `externalIdentifier`, `payoutPerShare`, `refundAtCost` | | `outcomes[].payoutPerShare` | number? | USDC paid per share, 0 to 1. Absent when the venue has not reported it. A voided or split market pays less than 1 on every side (e.g. `0.5`), so check this before treating `winner: true` as a \$1 win | | `outcomes[].refundAtCost` | boolean | The venue voided the market and refunded each holder their stake at entry price | | `affectedUsersCount` | number | Users in your app with open positions | | `detailsPath` | string | API path to paginate affected user IDs | More than one `winner: true` means a split or voided market. Read `payoutPerShare` and `refundAtCost` before showing the user an amount. See [Market resolution & voids](/concepts/market-resolution). ### Querying affected users Use the SDK to paginate through affected users. Pass `includeEmails: true` to get email addresses where available. ```typescript theme={null} import { AggAdminClient } from "@agg-build/sdk/server"; const admin = new AggAdminClient({ baseUrl: "https://api.agg.market", apiKey: process.env.AGG_APP_API_KEY!, }); // Paginate through all affected users let cursor: string | undefined; do { const page = await admin.listMarketResolutionUsers( appId, event.data.venueMarketId, { limit: 100, cursor, includeEmails: true }, ); for (const user of page.data) { console.log(user.userId, user.email); } cursor = page.nextCursor ?? undefined; } while (cursor); ``` See the [List users affected by market resolution](/api-reference/partner-admin/list-users-affected-by-market-resolution) API reference for full details. > [!NOTE] > > This endpoint returns the current set of users with non-zero positions, not an immutable > snapshot at resolution time. If positions are modified after resolution, the results may differ > from the `affectedUsersCount` in the webhook. # Webhooks Source: https://docs.agg.market/recipes/webhooks/overview Receive real-time event notifications at your own HTTPS endpoints Socket events are for the signed-in browser on an open connection. Webhooks are HTTPS posts to your server. The socket stops when the tab closes. Failed webhook deliveries are retried on a bounded schedule. Use the socket for live UI and webhooks for the record of the same action. ```typescript theme={null} import { parseWebhookEvent } from "@agg-build/sdk/server"; const event = parseWebhookEvent(rawBody, req.headers, process.env.AGG_WEBHOOK_SECRET!); ``` ## How it works **1. Register an endpoint** Create a webhook endpoint from the **admin dashboard**. Select a URL and the event types you want to receive. The dashboard displays your signing secret (`whsec_...`) — store it in your secrets manager immediately. **2. Receive events** AGG sends `POST` requests to your URL with a JSON payload and `Webhook-Signature` header. **3. Verify the signature** Use `parseWebhookEvent` from the SDK to verify the signature and parse the event in one call. This prevents spoofed requests. **4. Return 2xx** Verify and durably record the event, then respond promptly with a `2xx` status. Process slow work asynchronously. Timeouts and non-2xx responses trigger the retry schedule below. ## Event types | Event | Trigger | Payload | | - | - | - | | `accounts.created` | New user created in your app | `userId`, `appId`, `email` | | `accounts.linked` | User linked to an external ID | `userId`, `externalId` | | `wallets.ready` | Server wallet provisioned | `userId`, `evmAddress`, `svmAddress` | | `trades.placed` | Trade order submitted | `orderId`, `userId`, `side` | | `trades.filled` | Trade order filled | `orderId`, `userId`, `status` | | `trades.fee_reconciled` | A self-custody trade's app fee reached its final state | `orders`, `status`, `feeCollectedRaw`, `appTxHash` | | `markets.resolved` | Market resolved (summary per app) | `venueMarketId`, `outcomes[]`, `affectedUsersCount` | | `deposits.confirmed` | On-chain deposit landed in a user's managed wallet | `userId`, `chainId`, `amountRaw`, `txHash` | `markets.resolved` carries `outcomes[]` (`label`, `winner`, `externalIdentifier`, `payoutPerShare`, `refundAtCost`). It does not carry a singular outcome. Shapes are on the [event reference](/recipes/webhooks/event-reference). > [!NOTE] > > Webhook payloads are intentionally minimal — they carry IDs and timestamps. Query the API for > full details. `accounts.created.email` is populated when AGG has an email from magic-link or > OAuth sign-in; otherwise it is `null`. ## Delivery behavior Deduplicate on the `Webhook-Id` header. It is the same value on every retry of a delivery. Failed deliveries retry after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and another 10 hours. Each delay starts after the preceding failure. After retries are exhausted, inspect the delivery and replay it after fixing your endpoint. Persistently failing endpoints can be disabled. Check endpoint status after an outage. Deliveries can be duplicated or arrive out of order, including for the same app. Use resource IDs to reconcile with current API state and [deduplicate atomically](/recipes/webhooks/verifying-signatures#idempotency). ## Signature headers | Header | Description | | - | - | | `Webhook-Id` | Message id. Deduplicate on this header. It is the same value on every retry of a delivery. | | `Webhook-Timestamp` | Unix timestamp of the delivery attempt | | `Webhook-Signature` | HMAC-SHA256 signature for verification | ## Rotate and replay Create, delete, pause, and resume endpoints, and change their URL or event types, in the admin dashboard. These two calls use `x-app-api-key` only. Send an empty JSON body. Rotate issues a new signing secret, returned once. You cannot read it back later. ```ts SDK theme={null} import { AggAdminClient } from "@agg-build/sdk/server"; const admin = new AggAdminClient({ baseUrl: "https://api.agg.market", apiKey: process.env.AGG_APP_API_KEY!, }); const { secret } = await admin.rotateWebhookSecret(appId, webhookId); ``` ```bash cURL theme={null} curl -X POST "https://api.agg.market/apps/$APP_ID/webhooks/$WEBHOOK_ID/rotate-secret" \ -H "x-app-api-key: $AGG_APP_API_KEY" \ -H "content-type: application/json" \ -d '{}' ``` [Rotate webhook signing secret](/api-reference/webhooks/rotate-webhook-signing-secret). Replay re-queues the endpoint's failed deliveries from the last 7 days and returns `{ success }`. ```ts SDK theme={null} import { AggAdminClient } from "@agg-build/sdk/server"; const admin = new AggAdminClient({ baseUrl: "https://api.agg.market", apiKey: process.env.AGG_APP_API_KEY!, }); const { success } = await admin.replayWebhookDeliveries(appId, webhookId); ``` ```bash cURL theme={null} curl -X POST "https://api.agg.market/apps/$APP_ID/webhooks/$WEBHOOK_ID/replay" \ -H "x-app-api-key: $AGG_APP_API_KEY" \ -H "content-type: application/json" \ -d '{}' ``` [Replay failed deliveries](/api-reference/webhooks/replay-failed-deliveries). ## Next steps > **[Verifying signatures](/recipes/webhooks/verifying-signatures)** > > Verify webhook authenticity with the SDK or manually > **[Event reference](/recipes/webhooks/event-reference)** > > Full payload schemas for every event type # Verifying Webhook Signatures Source: https://docs.agg.market/recipes/webhooks/verifying-signatures Verify that webhook deliveries are authentic and untampered Every webhook delivery is signed with the endpoint's signing secret using HMAC-SHA256. Verify the signature before processing any event. `parseWebhookEvent` (sync, `node:crypto`) and `parseWebhookEventAsync` (WebCrypto) throw `WebhookVerificationError` when the signature is invalid, the timestamp is stale (older than 5 minutes by default), or a required header is missing. Both return a typed `WebhookEvent` on success. Next.js app router uses the async function. ## The signature scheme The HMAC is computed over: ``` {webhook_id}.{timestamp}.{body} ``` * `webhook_id` is the `Webhook-Id` header * `timestamp` is the `Webhook-Timestamp` header (Unix seconds) * `body` is the raw request body (not parsed JSON) The result is base64-encoded and prefixed with `v1,`. The `Webhook-Signature` header may contain multiple space-separated signatures during key rotation. Accept any match. For a verifier in another language, remove the `whsec_` prefix from the signing secret and base64-decode the remainder to obtain the HMAC key. Compare signatures in constant time, and reject timestamps more than five minutes in the past **or future**. The SDK handles these checks. > [!WARNING] > > Always read the raw body for signature verification. Parsing JSON first and re-stringifying > changes whitespace and breaks the HMAC. ## Next.js (app router) ```typescript theme={null} // app/api/webhooks/agg/route.ts import { parseWebhookEventAsync, WebhookVerificationError } from "@agg-build/sdk/server"; export async function POST(req: Request) { try { const event = await parseWebhookEventAsync( await req.text(), Object.fromEntries(req.headers), process.env.AGG_WEBHOOK_SECRET!, ); switch (event.type) { case "trades.placed": await handleTrade(event.data); break; case "markets.resolved": await handleResolution(event.data); break; } return Response.json({ received: true }); } catch (err) { if (err instanceof WebhookVerificationError) { return Response.json({ error: err.message }, { status: 401 }); } throw err; } } ``` Use `parseWebhookEvent` where `node:crypto` exists, and `parseWebhookEventAsync` for WebCrypto. Next.js pages-router handlers need `export const config = { api: { bodyParser: false } }`. In Express, mount `express.raw({ type: "application/json" })` on the webhook route **before** any JSON middleware. Both must pass the original body bytes to the verifier. The handler above illustrates signature verification. For production processing, add durable deduplication as described below before running your event handlers. ## Idempotency Deduplicate on `Webhook-Id`, which stays the same across retries. Concurrent deliveries can overlap, so a separate check, process, and mark sequence is not sufficient. One option is a durable inbox in your own database. Create a unique key on the delivery ID: ```sql theme={null} CREATE TABLE agg_webhook_inbox ( id text PRIMARY KEY, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz ); ``` After verification, insert the event atomically. Here `db` is your server's database client with parameterized queries. Return 2xx only after the insert commits; database errors must return a failure so AGG can retry. ```typescript theme={null} async function handle(req: Request) { const event = await parseWebhookEventAsync( await req.text(), Object.fromEntries(req.headers), process.env.AGG_WEBHOOK_SECRET!, ); const webhookId = req.headers.get("webhook-id")!; await db.query( `INSERT INTO agg_webhook_inbox (id, payload) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [webhookId, JSON.stringify(event)], ); return Response.json({ received: true }); } ``` Your worker processes unprocessed rows and commits its database changes and `processed_at` together in one transaction. Use the delivery ID as an idempotency key for external side effects too. Keep processed IDs for your replay window, not just the automatic retry period; the [replay endpoint](/recipes/webhooks/overview#rotate-and-replay) can resend older deliveries. Events can arrive out of order. Use the event's resource IDs to read current API state before updating a balance or order display. See [delivery behavior](/recipes/webhooks/overview#delivery-behavior). # Real-Time Arbitrage Source: https://docs.agg.market/recipes/websocket-arb Stream live cross-venue arbitrage returns with the SDK and React hooks # Real-Time Arbitrage AGG real-time arbitrage streams help prediction market apps find cross-venue price dislocations as they happen. Use `arbReturn` to rank matched markets, power trading dashboards, alert users, or feed agent workflows with live arbitrage signals. `arbReturn` is the estimated cross-venue arbitrage return for a matched market — a decimal fraction (e.g. `0.012` = 1.2%) derived from the best prices across the venues in a matched cluster. It is available from three places: * **[List Venue Events](/api-reference/markets/list-venue-events)** — an `arbReturn` embedded in every listing row. Synced by a background job, so it can lag the live books; use it for initial render and sorting, not as a live signal. * **[Get Midpoints](/api-reference/market-data/get-live-midpoints-for-multiple-markets) with `bestPrice=true`** — computed fresh by the pricing engine on every request. The REST source of truth when you need a current value on demand (agents, alerts, one-off checks) without holding a socket open. * **WebSocket arb streams** — pushes updates as they happen. The right choice for anything that stays on screen. Two consumption modes: * **Per-market** — for a detail view of one or a few markets. * **Feed** — a single coalesced subscription for a whole listing, so you don't manage a subscription per visible row. ## What you get * `arbReturn` on venue event and market listings for initial page loads and sorting. `/search` results do not carry `arbReturn`; read it from `/midpoints` or the arb stream. * Per-market WebSocket updates for focused detail views. * A coalesced `arb-feed` stream for large lists and dashboards. * SDK helpers (`subscribeArb`, `subscribeArbFeed`) and React hooks (`useMarketArb`, `useArbFeed`). * Event-level rollups through `useArbFeed`, where `byEvent` keeps the max live `arbReturn` across markets in the same event. > [!NOTE] > > The stream is **delta-only and last-value-wins** — there is no snapshot or sequence number. Load > the initial `arbReturn` from the REST listing, then layer the stream on top. A market that hasn't > moved since page load simply won't appear in the stream yet, so always fall back to the listing > value. Updates are loss-tolerant. See the [WebSocket Protocol](/api/websocket#4-arbitrage-streams) > for the wire format and the [API Reference](/api-reference/markets/list-venue-events) for REST > request and response details. ## SDK (vanilla JS/TS) ```typescript theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", wsUrl: "wss://ws.agg.market/ws", }); const ws = client.createWebSocket(); // Per-market: subscribe returns an unsubscribe function. const offMarket = ws.subscribeArb("vm_1", (update) => { renderArb(update.marketId, update.arbReturn); }); // Last value received for a market (or null), e.g. to seed a re-render. const current = ws.getArb("vm_1"); // Feed: one subscription for the whole listing. const offFeed = ws.subscribeArbFeed((batch) => { for (const e of batch.entries) { updateRow(e.marketId, e.arbReturn); } }); // Clean up when done. offMarket(); offFeed(); ``` The SDK handles ref-counting (multiple subscribers to the same market share one server subscription) and re-subscribes automatically on reconnect. ## React (hooks) ### One market ```tsx theme={null} import { useMarketArb } from "@agg-build/hooks"; function ArbBadge({ marketId, initialArbReturn }: { marketId: string; initialArbReturn: number }) { const { arbReturn, isLive } = useMarketArb(marketId); // Fall back to the listing value until the first live update arrives. const value = arbReturn ?? initialArbReturn; return ( {(value * 100).toFixed(2)}%{isLive ? " ●" : ""} ); } ``` ### A listing (feed) `useArbFeed` maintains a live `byMarket` map and a derived `byEvent` map (the **max** `arbReturn` across the markets of each `venueEventId`), so you can render either market-level or event-level arbitrage live. ```tsx theme={null} import { useArbFeed } from "@agg-build/hooks"; function EventList({ events }: { events: VenueEvent[] }) { const { byEvent } = useArbFeed(); return (
    {events.map((event) => { // Live value if the feed has sent one for this event, else the listing value. const arb = byEvent.get(event.id) ?? event.arbReturn ?? 0; return (
  • {event.title} — {(arb * 100).toFixed(2)}%
  • ); })}
); } ``` > [!TIP] > > **Initial value:** the feed only carries markets whose value has *changed*, so a freshly loaded > row may not be in `byMarket`/`byEvent` yet. Always resolve with `feedValue ?? restValue` — read > `arbReturn` from your listing response as the fallback. > [!TIP] > > **Sort stability:** if you sort a listing by `arbReturn`, re-sorting on every flush makes rows > jump. Prefer updating values in place and re-sorting only on an explicit user action or the next > listing refetch. ## FAQ ### What is prediction market arbitrage? Prediction market arbitrage is a cross-venue price dislocation where matched markets imply a positive return if the relevant outcomes can be bought or hedged at the displayed prices. AGG calculates this signal on matched market clusters so apps can spot opportunities across venues without building their own matching and price-comparison layer. ### What does `arbReturn` mean? `arbReturn` is the estimated arbitrage return expressed as a decimal fraction. For example, `0.012` means an estimated 1.2% cross-venue return for that matched market based on the best prices AGG sees in the cluster. Use it as a ranking, alerting, or trading signal, and render it as a percentage in user-facing views. ### Is `arbReturn` guaranteed execution? No. `arbReturn` is a real-time market signal, not a guaranteed fill or guaranteed profit. Execution can still depend on venue availability, liquidity, routeability, fees, stale prices, slippage, and order timing. Before placing trades, check the relevant market and outcome routeability fields and use AGG's routing and execution APIs for executable quotes. ### Should I use REST or WebSocket data? Use the venue-event listing to load the initial page, filters, and fallback `arbReturn` values, then use WebSocket data to keep visible markets live. The WebSocket stream is delta-only, so it does not replace the listing response. If you need a fresh value over REST, call `GET /midpoints?bestPrice=true`. The SDK sends at most 75 ids per request. The socket subscribe stays in the SDK above. The frame is on the [WebSocket Protocol](/api/websocket#4-arbitrage-streams). The ack field `outcomeIds` contains the `marketIds` you subscribed. There is no midpoint channel. ```ts SDK theme={null} const midpoints = await client.getMidpoints({ venueMarketIds: ["vm_1"], bestPrice: true, }); ``` ```bash cURL theme={null} curl -G "https://api.agg.market/midpoints" \ -H "x-app-id: $AGG_APP_ID" \ --data-urlencode "venueMarketIds=vm_1" \ --data-urlencode "bestPrice=true" ``` [Get live midpoints for multiple markets](/api-reference/market-data/get-live-midpoints-for-multiple-markets). `arbReturn` on this call is computed at request time. The listing value can lag. For request schemas and parameters, use the [List Venue Events API reference](/api-reference/markets/list-venue-events); for live message shapes, use the [WebSocket Protocol](/api/websocket#4-arbitrage-streams). ## Related > **[WebSocket Protocol](/api/websocket#4-arbitrage-streams)** > > The arbitrage stream wire format — channels, subscribe frames, and message shapes. > **[List Venue Events API](/api-reference/markets/list-venue-events)** > > REST schema for loading initial `arbReturn` values, filters, pagination, and event rows. > **[Get Midpoints API](/api-reference/market-data/get-live-midpoints-for-multiple-markets)** > > Fresh, engine-computed `arbReturn` over REST — pass `bestPrice=true`. > **[Comparing Venue Prices](/recipes/comparing-venue-prices)** > > Cross-venue price comparison the arbitrage return is derived from. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Live aggregated orderbook subscriptions over the same socket. > **[Setup Guide](/quickstart/react)** > > Client setup, provider wiring, and the full hooks reference. # Real-Time Charts Source: https://docs.agg.market/recipes/websocket-charts Load immutable chart history from the manifest, then the unfinished tail from chart bars Chart history is keyed by `venueMarketOutcomeId`. The same path as [Market-data API](/api/market-data): `GET /charts/history/manifest` returns authorized references to immutable CDN chunks, then `GET /charts/bars` returns the unfinished tail. The manifest returns at most eight CDN chunks. Fetch those URLs yourself, then fetch the tail with `/charts/bars`. The API does not merge candles. ```ts SDK theme={null} const manifest = await client.getChartHistoryManifest({ venueMarketOutcomeId, to, }); const tail = await client.getChartBars({ venueMarketOutcomeId, to, }); ``` ```bash cURL theme={null} curl -s -H "x-app-id: $AGG_APP_ID" \ "https://api.agg.market/charts/history/manifest?venueMarketOutcomeId=$OUTCOME_ID&to=$TO_MS" curl -s -H "x-app-id: $AGG_APP_ID" \ "https://api.agg.market/charts/bars?venueMarketOutcomeId=$OUTCOME_ID&to=$TO_MS" ``` `to` is Unix milliseconds and is required on both calls. History references are [Get versioned chart history references](/api-reference/market-data/get-versioned-chart-history-references). The tail is [Get chart bars](/api-reference/market-data/get-chart-bars). `mergeCandles()` in `@agg-build/sdk` is a client-side volume-weighted merge of candle objects the caller already has. It is not a chart API parameter, and the API does not merge candles. ## Supported resolutions The SDK takes the human-readable form and translates it. REST uses `resolution`. There is no `interval` parameter. Sending `interval` does not choose a bar size. Omit `resolution` and the call uses the default `5` (5 minutes). A `resolution` other than `1`, `5`, `60`, or `1D` is rejected. | Bar size | SDK (`resolution`) | REST (`?resolution=`) | | - | - | - | | 1 minute | `"1m"` | `1` | | 5 minutes | `"5m"` | `5` | | 1 hour | `"1h"` | `60` | | 1 day | `"1d"` | `1D` | Live orderbook and trade frames, if you draw a forming bar yourself, are on [WebSocket Protocol](/api/websocket). There is no midpoint channel. ## Related > **[Market-data API](/api/market-data)** > > Manifest, tail, and the other market-data reads. > **[WebSocket Protocol](/api/websocket)** > > Orderbook and trade frames. No midpoint channel. > **[Real-Time Orderbook](/recipes/websocket-orderbook)** > > Subscribe with `venueMarketOutcomeId`. # User Notifications Source: https://docs.agg.market/recipes/websocket-notifications Reconnect an authenticated WebSocket and seed balances, positions, and orders from REST Authenticate the socket to receive updates for the signed-in user. Message shapes are in [WebSocket Protocol](/api/websocket#3-authenticated-user-events). No initial state is sent when the socket authenticates. Seed balances, positions, and orders from `GET /execution/balances`, `GET /execution/positions`, and `GET /execution/orders`, then apply socket updates. See [Portfolio](/recipes/portfolio) and [Order lifecycle](/concepts/order-lifecycle). A fill is the socket event `order_event` with `event` `filled` or `partial_fill`, and the webhook event `trades.filled`. See [Webhooks](/recipes/webhooks/overview). ## Refresh-aware reconnect The socket's user session lasts as long as the access token. The gateway sends `auth_expiring` about a minute before expiry, and `auth_expired` when the session ends. Refresh the token and send `authenticate` again. When you reconnect, pass the last `eventId` you processed as `resumeFrom`, and events sent while you were away are replayed. Seed from REST only when the `resumed` frame says `complete: false`. ```typescript theme={null} let accessToken = initialAccessToken; let lastEventId: string | undefined; const seen = new Set(); function connect(attempt = 0) { const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`); const authenticate = () => ws.send(JSON.stringify({ action: "authenticate", token: accessToken, resumeFrom: lastEventId })); ws.onmessage = async (frame) => { const msg = JSON.parse(frame.data); switch (msg.type) { case "connected": attempt = 0; authenticate(); return; case "auth_expiring": case "auth_expired": try { accessToken = await client.refreshAccessToken(); authenticate(); } catch { await client.signOut(); ws.close(); } return; case "resumed": if (!msg.complete) await seedFromRest(); // some events may be missing return; } if (msg.eventId) { if (seen.has(msg.eventId)) return; // a resume can resend events you have seen.add(msg.eventId); lastEventId = msg.eventId; } applyUserEvent(msg); }; ws.onclose = (event) => { if (event.code === 4001 || event.code === 4003) return; // configuration error const delay = Math.min(1000 * 2 ** attempt, 30_000) * (0.5 + Math.random() / 2); setTimeout(() => connect(attempt + 1), delay); }; } await seedFromRest(); connect(); ``` The SDK does all of this for you: `client.createWebSocket()` authenticates by message, refreshes on `auth_expiring`, resumes from the last `eventId`, de-duplicates, and reports `onResumed`. ## Server-side: every user's events A backend can receive all users' events for the app on one API-key connection instead of one socket per user. See [App events](/api/websocket#5-app-events-server-to-server). ## Related > **[WebSocket Protocol](/api/websocket#3-authenticated-user-events)** > > Authenticated user-event frames. > **[Order lifecycle](/concepts/order-lifecycle)** > > Statuses to poll when the socket was down. > **[Portfolio](/recipes/portfolio)** > > Activity and the leaderboard. > **[Webhooks](/recipes/webhooks/overview)** > > `trades.filled` on your server. # Real-Time Orderbook Source: https://docs.agg.market/recipes/websocket-orderbook Stream a live aggregated orderbook with the SDK, hooks, or UI components The AGG WebSocket delivers a full aggregated orderbook snapshot on subscribe, then incremental deltas as the book changes. Each outcome has its own orderbook — there is no complement derivation. Each level includes per-venue attribution so you can render both the aggregated depth and the venue breakdown from the same stream. > [!WARNING] > > **Orderbook subscriptions are keyed by `venueMarketOutcomeId`** — the `outcomes[].id` on REST > market responses — not by `venueMarketId`. Subscribing with a `venueMarketId` never resolves a > book and the gateway replies with a `Snapshot unavailable` error. (Only the REST > `GET /orderbooks` endpoint and the arb streams use market-level ids.) For bounded REST snapshots and the shared single/batch outcome-unit quota, see [Market-data API](/api/market-data). That guide also explains `snapshotChecksum` versus the full engine checksum and optional native Worker ownership. **SDK (vanilla JS/TS)** Full control over the WebSocket connection and local orderbook state. ## 1. Connect and subscribe ```typescript theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", wsUrl: "wss://ws.agg.market/ws", }); let book = null; const ws = client.createWebSocket({ onSnapshot: (_outcomeId, nextBook) => { book = nextBook; renderOrderbook(book); }, onDelta: (_outcomeId, nextBook) => { book = nextBook; renderOrderbook(book); }, onError: (msg) => { console.error(msg.message); }, }); const venueMarketOutcomeId = "vmo_1"; ws.subscribe(venueMarketOutcomeId, "orderbook"); ``` The SDK applies snapshots and deltas for you, tracks `seq`, validates `checksum` (XOR of per-level CRC32), drops stale deltas where `seq <= current.seq`, and requests a fresh snapshot when integrity checks fail. It also runs a 5-minute book watchdog; see [Integrity](#integrity). ## 2. Read the orderbook Orderbook `timestamp` is Unix milliseconds on the wire, and the SDK reports it as seconds. The `OrderbookState` passed to callbacks uses object-shaped levels: ```typescript theme={null} interface OrderbookState { outcomeId: string; bids: Array<{ price: number; size: number; venues: Record }>; asks: Array<{ price: number; size: number; venues: Record }>; venueOrderbooks: Record< string, { bids: Array<{ price: number; size: number }>; asks: Array<{ price: number; size: number }>; } >; venues: Record; midpoint: number | null; spread: number | null; seq: number; checksum: number; timestamp: number; // seconds (SDK converts wire milliseconds) } ``` ## 3. Per-venue orderbooks Per-venue depth is available on the same object: ```typescript theme={null} const ws = client.createWebSocket({ onSnapshot: (_outcomeId, book) => { console.log(book.venueOrderbooks.predict?.bids); console.log(book.venueOrderbooks.polymarket?.asks); }, }); ``` ## 4. REST fallback For one-time fetches without a live socket: ```typescript theme={null} const response = await client.getOrderbooks({ venueMarketIds: ["your-market-id"], depth: 20, }); const book = response.data[0]; if (book?.status !== "ok") { throw new Error(book?.error?.message ?? "No live orderbook available"); } // book.venueOrderbooks — per-venue depth keyed by venue // book.matchedMarkets — venue markets considered for the requested market // book.requestedMarket — lifecycle metadata for the requested market ``` **Hooks (React)** > [!NOTE] > > See the [Setup Guide](/quickstart/react) for the one-time `AggProvider` and `QueryClientProvider` > wiring. This recipe starts at the hook usage layer. ## 1. Live orderbook ```tsx theme={null} import { useLiveMarket } from "@agg-build/hooks"; function Orderbook({ venueMarketOutcomeId }) { const { orderbook, isConnected, integrity } = useLiveMarket(venueMarketOutcomeId); if (!orderbook) return
Connecting...
; return (

Connected: {String(isConnected)}

Bids

{orderbook.bids.map((level) => (
{(level.price * 100).toFixed(0)}% — {level.size} contracts {" ("} {Object.entries(level.venues) .map(([venue, size]) => `${venue}: ${size}`) .join(", ")} {")"}
))}

Midpoint: {orderbook.midpoint} | Spread: {orderbook.spread}

Asks

{orderbook.asks.map((level) => (
{(level.price * 100).toFixed(0)}% — {level.size} contracts
))} {integrity === "resyncing" &&

Resyncing…

}
); } ``` ## 2. Per-venue orderbooks ```tsx theme={null} import { useMarketOrderbook } from "@agg-build/hooks"; function VenueOrderbooks({ venueMarketOutcomeId, venueOutcomes }) { const { data, isLoading } = useMarketOrderbook({ venueMarketOutcomeId, // outcomes[].id — NOT a venueMarketId venueOutcomes, // [{ venue: "predict", venueMarketOutcomeId: "..." }, ...] }); if (isLoading || !data) return
Loading...
; return (
{Object.entries(data.venueOrderbooks).map(([venue, book]) => (

{venue}

Bids: {book.bids.length} levels

Asks: {book.asks.length} levels

))}
); } ``` ## 3. REST-only (no WebSocket) ```tsx theme={null} import { useAggClient } from "@agg-build/hooks"; import { useQuery } from "@tanstack/react-query"; function StaticOrderbook({ marketId }) { const client = useAggClient(); const { data } = useQuery({ queryKey: ["orderbooks", marketId], queryFn: () => client.getOrderbooks({ venueMarketIds: [marketId], }), }); const book = data?.data[0]; // book?.venueOrderbooks, book?.matchedMarkets, book?.requestedMarket } ``` **UI Components** Drop-in React components for live orderbook depth, venue attribution, and chart/orderbook tabs. ## [Market Details](/components/events/market-details) card with orderbook tab ```tsx theme={null} import { MarketDetails } from "@agg-build/ui/events"; function OrderbookCard({ event, marketId }) { return ; } ``` The card renders bid/ask rows, venue logos, sizes, totals, and live updates without manual subscription code. For a full event page with both chart and orderbook tabs, use [`EventMarketPage`](/components/pages/event-market-page) as shown in [Real-Time Charts](/recipes/websocket-charts). Browse the live [Market Details reference](/components/events/market-details) and [Event Market Page reference](/components/pages/event-market-page). ## Integrity The SDK keeps each book correct without any code from you: * **Seq and checksum:** it applies deltas only in order, verifies every `checksum`, and requests a fresh snapshot when either check fails. * **Server-sent snapshots:** if AGG rebuilds a book, its `seq` restarts and the gateway sends a new snapshot with the first update from it. The SDK replaces the book with it. * **Connection liveness:** it reconnects with backoff after 45 seconds with no frame, and re-subscribes everything. Reconnects authenticate with the token from your latest `authenticate()` call. * **Resolution:** on `market_resolved` it drops the outcome's book and calls `onMarketResolved`. * **Book watchdog:** heartbeats only prove the connection is alive. Once a minute, the SDK checks any book with no update for 5 minutes against `GET /orderbooks/outcomes` (see [Market-data API](/api/market-data)). A matching `seq` means the market is just quiet. A different `seq` triggers a resnapshot, then a resubscribe if the next check still differs or the snapshot never arrives. The watchdog is on for sockets from `client.createWebSocket()`, which the hooks and UI components use too (always with the default timings). Each check costs one market-data quota unit per silent outcome, and it pauses for `Retry-After` on a `429`. With the SDK directly, tune or disable it: ```typescript theme={null} const ws = client.createWebSocket(callbacks, { bookWatchdog: { silenceMs: 10 * 60_000, checkIntervalMs: 2 * 60_000 }, // or: bookWatchdog: false }); ``` Watch it through `onDiagnostics`: ```typescript theme={null} const ws = client.createWebSocket({ onDiagnostics: (event) => { if (event.type === "book_watchdog_resync") { // { outcomeId, localSeq, serverSeq, action: "resnapshot" | "resubscribe" } console.warn("orderbook healed", event); } if (event.type === "book_watchdog_error") { // { status } — the REST check failed; the next tick retries } }, }); ``` If you are implementing the wire protocol yourself, follow [Staleness checks](/api/websocket#staleness-checks) and [Best practices](/api/websocket#best-practices) on the WebSocket Protocol page. ## Related > **[WebSocket Protocol](/api/websocket)** > > Wire format, sequencing, resnapshot requests, and heartbeat behavior. > **[Real-Time Charts](/recipes/websocket-charts)** > > Build live candles from the same orderbook and trade streams. > **[User Notifications](/recipes/websocket-notifications)** > > Authenticated order and balance events on the same WebSocket connection. # SDK reference Source: https://docs.agg.market/sdk/reference Every public @agg-build/sdk method, the HTTP operation it calls, and its API reference page `@agg-build/sdk` is a thin client over the REST API. Each method below calls one HTTP operation. Follow the link for the full request and response schema. ```ts theme={null} import { createAggClient } from "@agg-build/sdk"; const client = createAggClient({ baseUrl: "https://api.agg.market", appId: "your-app-id", }); ``` `createAggClient()` returns an `AggClient`. It adds the `x-app-id` and `Authorization` headers for you and keeps the user's session. See [Base URL, app IDs & API keys](/environments) for the base URL and [Quickstart: REST](/quickstart/rest) for a full trade. ## Auth | Method | HTTP | Reference | | - | - | - | | `authStart()` | `POST /auth/start` | [Start authentication](/api-reference/authentication/start-authentication) | | `startAuth()` | Alias of `authStart()` | [Start authentication](/api-reference/authentication/start-authentication) | | `buildSiweMessage()` | None. Builds the EIP-4361 message locally | [Quickstart: REST](/quickstart/rest) | | `buildSiwsMessage()` | None. Builds the Solana sign-in message locally | [Quickstart: REST](/quickstart/rest) | | `verify()` | `POST /auth/verify` | [Verify sign-in](/api-reference/authentication/verify-sign-in) | | `verifySignIn()` | Alias of `verify()` | [Verify sign-in](/api-reference/authentication/verify-sign-in) | | `exchangeAuthCode()` | `POST /auth/token/exchange` | [Complete redirect sign-in](/api-reference/authentication/complete-redirect-sign-in) | | `refreshAccessToken()` | `POST /auth/token/refresh` | [Refresh access token](/api-reference/authentication/refresh-access-token) | | `signOut()` | `POST /auth/sign-out` | [Sign out](/api-reference/authentication/sign-out) | | `setSession()` | None. Stores tokens from a redirect callback | [Authentication](/concepts/authentication) | | `setAccessToken()` | None. Replaces the stored access token | [Authentication](/concepts/authentication) | | `clearAccessToken()` | None. Clears the local session | [Authentication](/concepts/authentication) | | `onAuthStateChange()` | None. Subscribes to sign-in and sign-out | [Authentication](/concepts/authentication) | Read-only session properties: `isAuthenticated`, `authStatus`, `user`, `address`, `appId`. ## Users | Method | HTTP | Reference | | - | - | - | | `getCurrentUser()` | `GET /users/me` | [Get current user](/api-reference/users/get-current-user) | | `updateUser()` | `PATCH /users/me` | [Update username](/api-reference/users/update-username) | | `linkExternalId()` | `POST /users/me/external-id` | [Link partner external user ID](/api-reference/users/link-partner-external-user-id-hmac-verified) | | `createAvatarUploadUrl()` | `POST /users/me/avatar` | [Get avatar upload URL](/api-reference/users/get-avatar-upload-url) | | `linkAccount()` | `POST /users/me/link-account/start` | [Start account linking](/api-reference/users/start-account-linking) | | `linkAccountConfirm()` | `POST /users/me/link-account/confirm` | [Confirm account linking](/api-reference/users/confirm-account-linking) | | `disconnectAccount()` | `DELETE /users/me/accounts/{provider}` | [Disconnect auth provider](/api-reference/users/disconnect-auth-provider) | | `getSharedKycStatus()` | `GET /kyc/status` | [Get shared KYC status](/api-reference/users/get-shared-kyc-status) | | `initiateKyc()` | `POST /kyc/{venue}` | [Initiate KYC](/api-reference/users/initiate-kyc) | | `upsertVenueKey()` | `PUT /venue-keys` | [Upsert a venue API key](/api-reference/users/upsert-a-venue-api-key) | | `listVenueKeys()` | `GET /venue-keys` | [List venue keys](/api-reference/users/list-venue-keys) | | `getVenueKeyStatus()` | `GET /venue-keys/{venue}/status` | [Get venue key connection status](/api-reference/users/get-venue-key-connection-status) | | `deleteVenueKey()` | `DELETE /venue-keys/{venue}` | [Delete a venue key](/api-reference/users/delete-a-venue-key) | ## Markets | Method | HTTP | Reference | | - | - | - | | `search()` | `GET /search` | [Search events or markets](/api-reference/markets/search-events-or-markets) | | `getVenueEvents()` | `GET /venue-events` | [List venue events](/api-reference/markets/list-venue-events) | | `getVenueEventById()` | `GET /venue-events/{id}` | [Get venue event](/api-reference/markets/get-venue-event) | | `getEventBootstrap()` | `GET /venue-events/{id}/bootstrap` | [Get the first useful event view](/api-reference/markets/get-the-first-useful-event-view) | | `getVenueMarkets()` | `GET /venue-markets` | [List venue markets](/api-reference/markets/list-venue-markets) | | `getCategories()` | `GET /categories` | [List categories](/api-reference/markets/list-categories) | | `listRecurringCryptoMarkets()` | `GET /crypto/recurring-markets` | [List recurring crypto window markets](/api-reference/markets/list-recurring-crypto-window-markets) | | `getAppConfig()` | `GET /app/config`. Public UI config for this app | [Get the per-app UI config](/api-reference/markets/get-the-per-app-ui-config-disabled-venues-+-categories) | | `getNewsFeeds()` | `GET /news-feed` | [List available news feeds](/api-reference/news/list-available-news-feeds-with-ids-and-item-counts) | | `getNewsFeed()` | `GET /news-feed/{category}` | [Article-centric news feed](/api-reference/news/article-centric-news-feed-for-a-category--latest-articles-with-linked-markets-and-live-prices) | | `getNewsFeedMarkets()` | `GET /news-feed/{category}/markets` | [Market-centric news feed](/api-reference/news/market-centric-news-feed--markets-grouped-with-their-news-timeline) | | `getMarketNews()` | `POST /news-feed/market-news` | [News for markets](/api-reference/news/get-news-articles-for-markets-with-market-impact-summaries) | | `getMarketCorrelatedSignals()` | `GET /correlated-markets/{venueMarketId}` | [Correlated market signals](/api-reference/correlated-markets/get-correlated-market-signals-for-a-market) | | `queryCorrelatedMarkets()` | `POST /correlated-markets/query` | [Find correlated markets](/api-reference/correlated-markets/find-correlated-markets-by-semantic-similarity) | | `getCorrelatedMarketCascade()` | `GET /correlated-markets/cascade/{venueMarketId}` | [Ripple effect of a resolution](/api-reference/correlated-markets/get-ripple-effect-of-a-market-resolving-by-market-id) | | `resolveCorrelatedMarkets()` | `POST /correlated-markets/resolve` | [Resolve a market and return cascade](/api-reference/correlated-markets/resolve-a-market-by-venue-id-slug-or-text-and-return-cascade) | `GET /venue-market-outcomes` has no dedicated method. Call it with `request()`. See [Look up venue market outcomes by external identifier](/api-reference/markets/look-up-venue-market-outcomes-by-external-identifier). ## Market data | Method | HTTP | Reference | | - | - | - | | `getOutcomeOrderbooks()` | `GET /orderbooks/outcomes` | [Get bounded outcome snapshots](/api-reference/market-data/get-bounded-outcome-snapshots) | | `getOrderbooks()` | `GET /orderbooks` | [Get live orderbooks](/api-reference/market-data/get-live-orderbooks-for-multiple-markets) | | `getOutcomeOrderbook()` | `GET /orderbook/outcome/{outcomeId}` | [Get live orderbook for one outcome](/api-reference/market-data/get-live-orderbook-for-a-single-outcome) | | `getMidpoints()` | `GET /midpoints` | [Get live midpoints](/api-reference/market-data/get-live-midpoints-for-multiple-markets) | | `getChartBars()` | `GET /charts/bars` | [Get chart bars](/api-reference/market-data/get-chart-bars) | | `getChartHistoryManifest()` | `GET /charts/history/manifest` | [Get versioned chart history references](/api-reference/market-data/get-versioned-chart-history-references) | | `getCryptoReferencePrices()` | `POST /crypto/reference-prices` | [Get live crypto reference prices](/api-reference/market-data/get-live-crypto-reference-prices) | | `getSportsLive()` | `GET /sports/live` | [Get live sports scores](/api-reference/market-data/get-live-sports-scores) | ## Trading | Method | HTTP | Reference | | - | - | - | | `getSmartRoute()` | `GET /orderbook/{venueMarketOutcomeId}/route` | [Get a quote](/api-reference/trading/get-a-quote-smart-route) | | `executeManaged()` | `POST /execution/fill` | [Execute quote](/api-reference/trading/execute-quote) | | `fillSelfCustody()` | `POST /execution/fill`, then polls status and submits signatures | [Self-custody trading](/recipes/self-custody) | | `submitFillSignatures()` | `POST /execution/fill/{quoteId}/signatures` | [Submit signatures](/api-reference/trading/submit-signatures-for-a-pending-self-custody-fill) | | `getExecutionStatus()` | `GET /execution/status` | [Get execution status](/api-reference/trading/get-execution-status) | | `placeOrder()` | `POST /execution/orders` | [Place an order directly](/api-reference/trading/place-an-order-directly) | | `placeLimitOrder()` | `POST /execution/limit-orders` | [Place limit order](/api-reference/trading/place-limit-order) | | `cancelManagedOrder()` | `POST /execution/orders/cancel` with `orderId` | [Cancel an order](/api-reference/trading/cancel-an-order) | | `cancelVenueOrder()` | `POST /execution/orders/cancel` for an order placed outside AGG | [Cancel an order](/api-reference/trading/cancel-an-order) | | `redeem()` | `POST /execution/redeem` | [Redeem winnings](/api-reference/trading/redeem-winnings-for-resolved-positions) | | `redeemSelfCustody()` | Sell quote on a resolved market, then `fillSelfCustody()` | [Self-custody trading](/recipes/self-custody) | | `getVenueGeoPolicy()` | `GET /app/venue-geo-policy` | [Per-venue blocked countries](/api-reference/trading/get-the-per-venue-blocked-country-lists-iso-3166-1-alpha-2) | ## Portfolio | Method | HTTP | Reference | | - | - | - | | `getManagedBalances()` | `GET /execution/balances` | [Get balances](/api-reference/portfolio/get-balances) | | `getPositions()` | `GET /execution/positions` | [Get user positions](/api-reference/portfolio/get-user-positions) | | `getExecutionPositions()` | `GET /execution/positions` with cursor pagination | [Get user positions](/api-reference/portfolio/get-user-positions) | | `getExecutionOrders()` | `GET /execution/orders` | [Get user orders](/api-reference/portfolio/get-user-orders) | | `getUserActivity()` | `GET /users/activity` | [Get user activity feed](/api-reference/portfolio/get-user-activity-feed) | | `getLeaderboard()` | `GET /leaderboard` | [Trader leaderboard](/api-reference/portfolio/trader-leaderboard) | | `getLeaderboardSeries()` | `GET /leaderboard/series` | [Trader leaderboard series](/api-reference/portfolio/trader-leaderboard-series) | ## Funding | Method | HTTP | Reference | | - | - | - | | `getDepositAddresses()` | `GET /execution/deposit-addresses` | [Get deposit addresses](/api-reference/funding/get-deposit-addresses) | | `getWithdrawalQuote()` | `POST /execution/withdrawable/quote` | [Quote withdrawable amount](/api-reference/funding/quote-withdrawable-amount) | | `withdrawPreview()` | `POST /execution/withdraw/preview` | [Preview a withdrawal](/api-reference/funding/preview-a-withdrawal) | | `withdrawManaged()` | `POST /execution/withdraw` | [Withdraw funds](/api-reference/funding/withdraw-funds) | | `getWithdrawalStatus()` | `GET /execution/withdrawals/{withdrawalId}` | [Get withdrawal status](/api-reference/funding/get-withdrawal-status) | | `getBalanceRefillPolicies()` | `GET /execution/balance-refill-policies` | [List refill policies](/api-reference/funding/list-managed-balance-refill-policies) | | `createBalanceRefillPolicy()` | `POST /execution/balance-refill-policies` | [Create a refill policy](/api-reference/funding/create-a-managed-balance-refill-policy) | | `updateBalanceRefillPolicy()` | `PATCH /execution/balance-refill-policies/{policyId}` | [Update or pause a refill policy](/api-reference/funding/update-or-pause-a-managed-balance-refill-policy) | | `getBalanceRefillAttempts()` | `GET /execution/balance-refill-policies/{policyId}/attempts` | [List refill attempts](/api-reference/funding/list-recent-managed-balance-refill-attempts) | | `getRampQuotes()` | `POST /ramp/quotes` | [Get crypto purchase quotes](/api-reference/funding/get-crypto-purchase-quotes) | | `createRampSession()` | `POST /ramp/session` | [Create a ramp widget session](/api-reference/funding/create-a-ramp-widget-session-for-checkout) | | `getHostedVenueAccount()` | `GET /users/me/venue-accounts/{venue}` | [Get hosted venue account](/api-reference/hosted-venue-accounts/get-hosted-venue-account) | | `provisionHostedVenueAccount()` | `POST /users/me/venue-accounts/{venue}` | [Provision hosted venue account](/api-reference/hosted-venue-accounts/provision-hosted-venue-account) | | `getHostedVenueActivity()` | `GET /users/me/venue-accounts/{venue}/activity` | [Get hosted venue account activity](/api-reference/hosted-venue-accounts/get-hosted-venue-account-activity) | | `getHostedVenueDepositAddress()` | `GET /users/me/venue-accounts/{venue}/deposit-address` | [Get hosted venue deposit address](/api-reference/hosted-venue-accounts/get-hosted-venue-deposit-address) | | `withdrawHostedVenue()` | `POST /users/me/venue-accounts/{venue}/withdrawals` | [Withdraw hosted venue funds](/api-reference/hosted-venue-accounts/withdraw-hosted-venue-funds) | | `getHostedVenueWithdrawal()` | `GET /users/me/venue-accounts/{venue}/withdrawals/{withdrawalId}` | [Get hosted venue withdrawal](/api-reference/hosted-venue-accounts/get-hosted-venue-withdrawal) | ## Paper trading `mode: "paper"` on `getSmartRoute()` / `executeManaged()` is the user path (user JWT plus `x-app-id`). The methods below are the admin path. They call `/apps/{appId}/paper-trading/...` and need a server-side `x-app-api-key`. See [Paper trading](/recipes/paper-trading). | Method | HTTP | Reference | | - | - | - | | `createPaperTradingAccount()` | `POST .../accounts` | [Create a paper trading account](/api-reference/paper-trading/create-a-paper-trading-account) | | `listPaperTradingAccounts()` | `GET .../accounts` | [List paper trading accounts](/api-reference/paper-trading/list-paper-trading-accounts) | | `getPaperTradingAccount()` | `GET .../accounts/{accountId}` | [Get a paper trading account](/api-reference/paper-trading/get-a-paper-trading-account) | | `setPaperTradingBalance()` | `PATCH .../accounts/{accountId}/balance` | [Set a paper account cash balance](/api-reference/paper-trading/set-a-paper-trading-account-cash-balance) | | `resetPaperTradingAccount()` | `POST .../accounts/{accountId}/reset` | [Reset a paper trading account](/api-reference/paper-trading/reset-a-paper-trading-account) | | `placePaperTradingOrder()` | `POST .../accounts/{accountId}/orders` | [Place a paper trading order](/api-reference/paper-trading/place-a-paper-trading-order) | | `listPaperTradingOrders()` | `GET .../accounts/{accountId}/orders` | [List paper trading orders](/api-reference/paper-trading/list-paper-trading-orders) | | `listPaperTradingPositions()` | `GET .../accounts/{accountId}/positions` | [List paper trading positions](/api-reference/paper-trading/list-paper-trading-positions) | | `getPaperTradingPortfolio()` | `GET .../accounts/{accountId}/portfolio` | [Get a paper trading portfolio](/api-reference/paper-trading/get-a-paper-trading-portfolio) | ## Real-time | Method | HTTP | Reference | | - | - | - | | `createWebSocket()` | WebSocket. Needs `wsUrl` in the client options. Runs the 5-minute book watchdog unless `bookWatchdog: false`. Server-side with `apiKey`, authenticates the handshake with `x-app-api-key`; pass `webSocketImpl` on runtimes without a global `WebSocket`. Resumes user events after reconnects | [Real-Time Orderbook](/recipes/websocket-orderbook#integrity), [Server connections](/api/websocket#server-connections) | | `socket.subscribeAppEvents(cb)` | Every user event of the app, on an API-key connection. Returns an unsubscribe function | [App events](/api/websocket#5-app-events-server-to-server) | ## Utilities | Method | What it does | | - | - | | `request()` | Calls any path with the client's headers. Retries once after a token refresh on `401` | | `destroy()` | Releases browser resources. Safe to call more than once | ## Not in the public API reference These methods exist in the SDK but call routes that are not part of the public API reference. They may change without notice. Do not build on them. `getOrders()`, `getUserHoldings()`, `getRpcToken()`, `getNewsFeedStatus()`, `getCorrelatedMarketsStatus()`, `recordDepositIntent()`, `syncManagedBalances()`. `syncManagedBalances()` is not a public route. Do not call `POST /execution/sync-balances`. ## Server module `@agg-build/sdk/server` is for your backend only. It uses your app's `x-app-api-key`, which must never reach a browser. ```ts theme={null} import { AggAdminClient, verifyWebhook } from "@agg-build/sdk/server"; const admin = new AggAdminClient({ baseUrl: "https://api.agg.market", apiKey: process.env.AGG_APP_API_KEY!, }); ``` ### Webhooks and partner admin (`AggAdminClient`) | Method | HTTP | Reference | | - | - | - | | `listUsers()` | `GET /apps/{appId}/users` | [List users](/api-reference/partner-admin/list-users) | | `getUser()` | `GET /apps/{appId}/users/{userId}` | [Get user](/api-reference/partner-admin/get-user) | | `listOrders()` | `GET /apps/{appId}/orders` | [List orders across your app](/api-reference/partner-admin/list-orders-across-your-app) | | `getAnalytics()` | `GET /apps/{appId}/analytics` | [Get analytics](/api-reference/partner-admin/get-analytics) | | `listMarketResolutionUsers()` | `GET /apps/{appId}/market-resolutions/{venueMarketId}/users` | [Users affected by a resolution](/api-reference/partner-admin/list-users-affected-by-market-resolution) | | `rotateWebhookSecret()` | `POST /apps/{appId}/webhooks/{webhookId}/rotate-secret` | [Rotate webhook signing secret](/api-reference/webhooks/rotate-webhook-signing-secret) | | `replayWebhookDeliveries()` | `POST /apps/{appId}/webhooks/{webhookId}/replay` | [Replay failed deliveries](/api-reference/webhooks/replay-failed-deliveries) | `AggAdminClient` also has `request()` for any other partner-admin route. ### Helper functions | Function | What it does | Guide | | - | - | - | | `verifyWebhook()` | Checks a delivery's signature and timestamp. Throws if either is invalid | [Verifying webhook signatures](/recipes/webhooks/verifying-signatures) | | `verifyWebhookAsync()` | Same as `verifyWebhook()`, for edge runtimes (Web Crypto) | [Verifying webhook signatures](/recipes/webhooks/verifying-signatures) | | `parseWebhookEvent()` | Verifies the delivery, then returns it as a typed event | [Webhook event reference](/recipes/webhooks/event-reference) | | `parseWebhookEventAsync()` | Same as `parseWebhookEvent()`, for edge runtimes (Web Crypto) | [Webhook event reference](/recipes/webhooks/event-reference) | | `signExternalId()` | Signs your internal user ID for external ID linking | [External ID linking](/recipes/external-id-linking) | # Kalshi Source: https://docs.agg.market/venues/kalshi How AGG trades Kalshi: the user's own Kalshi account and API key, cash held at Kalshi, and no on-chain step. AGG trades Kalshi through Kalshi's REST API, using the user's own Kalshi API key. The user's positions and cash stay in their Kalshi account. Nothing is tokenized, and no part of a Kalshi trade runs on a chain. The slug is `kalshi`. Every venue is compared on [Venues](/venues/overview). ## What the user needs first 1. A Kalshi account with an API key: a key id and its RSA private key. 2. A provisioned trading wallet. Call [Get deposit addresses](/api-reference/funding/get-deposit-addresses) (`client.getDepositAddresses()`) once. Storing the key fails without one. 3. The key stored with [Venue keys](/recipes/venue-keys). AGG reads the Kalshi balance with it before saving. A key that cannot read the balance returns `400` and is not stored. 4. Cash in the Kalshi account. The user deposits on Kalshi. AGG does not deposit to or withdraw from a Kalshi account. AGG runs no KYC for Kalshi. `POST /kyc/kalshi` returns `not_applicable`. See [KYC](/recipes/kyc). ## Funding A Kalshi leg spends the user's Kalshi cash. There is no bridge, approval, or deposit step, so a Kalshi fill is one REST order. Read that cash as `venueCash` on the balances call. It is in integer US cents. ```ts SDK theme={null} const { venueCash } = await client.getManagedBalances(); const kalshi = venueCash?.find((v) => v.venue === "kalshi"); // kalshi?.balanceCents: 1234 = $12.34. No kalshi entry without a readable key. ``` ```bash cURL theme={null} curl -s -H "x-app-id: $APP_ID" -H "Authorization: Bearer $ACCESS_TOKEN" \ "$API/execution/balances" ``` [Get balances](/api-reference/portfolio/get-balances). A quote for a buy that routes to Kalshi checks that cash. When the stored key reads a balance below the Kalshi leg's cost, the quote carries a warning with `reason: "low_balance"`. The quote still returns. At fill, a buy whose Kalshi cash is short fails with `quote_insufficient_balance`. That includes a user with no stored key, so leave `kalshi` out of `allowedVenues` for those users. A managed limit buy checks the same cash and returns `409` when it is short. ## Self-custody A self-custody route can include a Kalshi leg. It runs the same way: the user's Kalshi key signs and their Kalshi cash pays, with no wallet prompt. Self-custody limit orders are not supported on Kalshi. Fee rules for a Kalshi-only self-custody fill are on [Self-custody](/recipes/self-custody#venue-and-funding-support). ## Orders * A market fill is sent as an immediate-or-cancel order. Size that does not fill is cancelled, not left on the book. * Each order carries AGG's order id as Kalshi's client order id, so a retried submit does not place a second order. * An order on `No` is placed on Kalshi's `Yes` book at the complementary price. You still pass the `No` outcome's `venueMarketOutcomeId`. * Limit prices must sit on Kalshi's \$0.01 tick, for example `530000` (0.53 in 6-decimal units). * Limit orders accept `GTC`, `GTD`, `FOK`, and `IOC`, with post-only. See [Limit orders](/recipes/limit-orders). * `skipQuote` is not available on Kalshi. See [Direct order execution](/recipes/direct-order-execution#skipping-the-quote). ## Fees Kalshi charges its own trading fee, rounded up to the cent on each order. See [Venue fees](/recipes/venue-fees). A user-paid referral on a route that fills only on Kalshi cannot be paid. The quote carries a warning with `reason: "referral_unpaid_on_solana"`. See [Where a referral cannot be paid](/concepts/fees#where-a-referral-cannot-be-paid). ## Where it can be traded AGG blocks Kalshi fills from the United States as platform policy, although Kalshi itself accepts US users. Kalshi's own restricted countries are blocked too. Read the live list on [Venue geo availability](/recipes/venue-geo-availability). Do not hard-code it. ## Settlement Kalshi pays a settled position into the user's Kalshi cash itself. There is no redeem step, and `POST /execution/redeem` does not apply. A cancelled or postponed market settles at a fair price instead of voiding. See [Market resolution](/concepts/market-resolution#by-venue). ## IDs `externalIdentifier` is Kalshi's ticker: the event ticker on an event, and the market ticker on a market. Kalshi puts the market ticker on the `Yes` outcome only. The `No` outcome's `externalIdentifier` is `null`, so find its `id` in the market's `venueMarketOutcomes[]`. See [IDs](/concepts/ids). # Venues Source: https://docs.agg.market/venues/overview Every venue AGG lists, whether it can be traded, which account it trades from, and what it settles in. Use the slug wherever the API takes a `venue`. This page is a map. Order types, fees, and country rules each have their own page, linked below the table. | Venue | Slug | Trading | Trades from | Collateral | Limit orders | `skipQuote` | | - | - | - | - | - | - | - | | Polymarket | `polymarket` | Yes | Managed balance, or [self-custody](/recipes/self-custody) | USDC.e on Polygon, wrapped to pUSD | Yes | No | | Kalshi | `kalshi` | Yes | The user's own Kalshi account, through their [venue key](/recipes/venue-keys). See [Kalshi](/venues/kalshi) | USD cash in that Kalshi account | Yes | No | | Limitless | `limitless` | Yes | Managed balance | USDC on Base | Yes | No | | Predict.fun | `predict` | Yes | Managed balance | USDT on BNB Chain | Yes | Yes | | Myriad | `myriad` | Yes | Managed balance | USD1 on BNB Chain | Yes | No | | Hyperliquid | `hyperliquid` | Yes | Managed balance, or [self-custody](/recipes/self-custody) | USDC on HyperCore | Yes | Yes | | PRED | `pred` | Yes, off by default | Managed balance | USDC on Base | Yes | No | | TipRun | `tiprun` | Yes, off by default | Managed balance | USDC on Arbitrum | No | No | | Novig | `novig` | Yes, off by default | Managed Solana wallet after [KYC](/recipes/kyc), or the user's own Novig account in Direct mode | USDC on Solana | Direct mode only, buys only | Yes | | ProphetX | `prophetx` | Yes, off by default | Managed Solana wallet after [KYC](/recipes/kyc), or the user's own ProphetX account in Direct mode | USDC on Solana | Direct mode only, buys only | Yes | | BetDEX | `betdex` | Yes, off by default | A [hosted venue account](/recipes/hosted-venue-accounts) | USDC on Solana, deposited to the hosted account | Yes | No | "Off by default" means the venue is disabled for a new app until AGG enables it for that app. Direct mode is enabled per organization. Ask your AGG contact for either. Read the venues disabled for your app from `disabledVenues` on [Get the per-app UI config](/api-reference/markets/get-the-per-app-ui-config-disabled-venues-+-categories) (`client.getAppConfig()`). `POST /execution/orders` on a venue that cannot be traded returns `400` with `code: "venue_not_executable"`. Do not retry it. On a venue disabled for your app it returns `400` with `code: "quote_app_blocked"`. Collateral is what the venue spends. On a managed-balance venue, a buy bridges into it for you. See [Funding](/concepts/funding). A Kalshi buy spends cash already in the user's Kalshi account and moves no funds. ## Where the details live | Topic | Page | | - | - | | Time in force, post-only, and minimum sizes per venue | [Limit orders](/recipes/limit-orders) | | Venues that accept `skipQuote` and what it skips | [Direct order execution](/recipes/direct-order-execution#skipping-the-quote) | | Each venue's trading fee | [Venue fees](/recipes/venue-fees) | | App fees, referrals, and where a referral cannot be paid | [Fees](/concepts/fees) | | Countries each venue blocks | [Venue geo availability](/recipes/venue-geo-availability) | | Which venues support self-custody | [Self-custody](/recipes/self-custody#venue-and-funding-support) | | How each venue settles, splits, and voids | [Market resolution](/concepts/market-resolution#by-venue) | | The venue's own ids on events, markets, and outcomes | [IDs](/concepts/ids) |