# Global analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/analytics/global-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/analytics/deltas Returns per-bucket deltas (volume, traders, fees etc. that happened *during* each bucket). Use for bar-chart visualisations of activity over time. # Global analytics pct change Source: https://docs.struct.to/api-reference/analytics/global-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/analytics/changes Returns % change for each metric over the requested lookback window. `null` fields mean the window predates the available data. # Global analytics snapshot Source: https://docs.struct.to/api-reference/analytics/global-analytics-snapshot https://api.struct.to/openapi.json get /polymarket/analytics/counts Returns the latest cumulative analytics metrics across all of Polymarket: volume, traders, txn counts, fees, yes/no splits, and buy distribution. # Global analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/analytics/global-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/analytics/timeseries Returns cumulative analytics values at the end of each bucket. Use for running-total line charts of global volume, traders, fees over time. # Polymarket Analytics API Source: https://docs.struct.to/api-reference/analytics/overview Global, platform-wide aggregates across all of Polymarket. The Analytics endpoints return global, platform-wide aggregates across all of Polymarket: snapshot counts, percentage changes over a lookback window, per-bucket deltas (candles), and cumulative timeseries. For analytics scoped to a single market, event, trader, tag, or builder, use the analytics endpoints inside those groups. ## Global aggregates * [Get a global analytics snapshot](/api-reference/analytics/global-analytics-snapshot) of current platform counts. * Measure platform-wide [percentage change](/api-reference/analytics/global-analytics-pct-change), [per-bucket candles](/api-reference/analytics/global-analytics-candles-per-bucket-deltas), and [cumulative timeseries](/api-reference/analytics/global-analytics-timeseries-cumulative). ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: counts } = await client.analytics.getCounts(); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/analytics/counts" \ -H "X-API-Key: YOUR_API_KEY" ``` Need analytics for a single entity? Use the per-entity analytics endpoints: [market analytics](/api-reference/market/market-analytics-pct-change), [event analytics](/api-reference/events/event-analytics-pct-change), [trader analytics](/api-reference/trader/trader-analytics-pct-change), [tag analytics](/api-reference/tags/tag-analytics-pct-change), and [builder analytics](/api-reference/builders/builder-percentage-change-over-a-lookback-window). ## Related * Guide: [Finding Market Movers](/guides/finding-market-movers) * SDK: [REST namespaces](/sdk/rest-api) * [Core concepts](/introduction/concepts) # Get Asset Price History Source: https://docs.struct.to/api-reference/assets/get-asset-price-history https://api.struct.to/openapi.json get /polymarket/asset-history Returns historical price data for supported crypto assets from Polymarket API # Get OHLC candles for a crypto asset Source: https://docs.struct.to/api-reference/assets/get-ohlc-candles-for-a-crypto-asset https://api.struct.to/openapi.json get /polymarket/asset-history/candlestick Retrieve OHLC candlestick data for a single crypto asset at the requested resolution. # Get bonds Source: https://docs.struct.to/api-reference/bonds/get-bonds https://api.struct.to/openapi.json get /polymarket/market/bonds Retrieve high-probability bond markets from the live in-memory market snapshot. # All tags with builder-attributed activity Source: https://docs.struct.to/api-reference/builders/all-tags-with-builder-attributed-activity https://api.struct.to/openapi.json get /polymarket/builders/global/tags Returns every tag that has builder-routed activity, with metrics aggregated across all builders. `distinct_builders` is exact; other `unique_*` columns sum per-builder uniques and may slightly over-count traders active under multiple builders. # Bucketed top-N with 'other' builder composition Source: https://docs.struct.to/api-reference/builders/bucketed-top-n-with-other-builder-composition https://api.struct.to/openapi.json get /polymarket/builders/composition Bucketed timeseries for stacked-bar / area charts. For each bucket in the range, returns up to `top_n` plus one rows: one per top-N builder plus an `other` aggregate. Top-N is fixed across the whole window so chart legends stay stable. # Builder analytics deltas (per-bucket change) Source: https://docs.struct.to/api-reference/builders/builder-analytics-deltas-per-bucket-change https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/analytics/deltas Returns the change within each bucket for one builder. Use for bar/candle charts. # Builder analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/builders/builder-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/analytics/timeseries Returns cumulative values at the end of each bucket for one builder. Use for running-total charts over time. # Builder percentage change over a lookback window Source: https://docs.struct.to/api-reference/builders/builder-percentage-change-over-a-lookback-window https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/analytics/changes Per-metric percentage change over the requested window for one builder. # Cohort retention for a builder Source: https://docs.struct.to/api-reference/builders/cohort-retention-for-a-builder https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/retention For each cohort day in the requested window, returns the cohort size and the share of traders that came back on day +1, +7, and +30. # Cumulative bucket timeseries across all builders Source: https://docs.struct.to/api-reference/builders/cumulative-bucket-timeseries-across-all-builders https://api.struct.to/openapi.json get /polymarket/builders/global/analytics/timeseries # Cumulative stats across all builders Source: https://docs.struct.to/api-reference/builders/cumulative-stats-across-all-builders https://api.struct.to/openapi.json get /polymarket/builders/global Returns one snapshot summing every builder-attributed trade over the chosen window. # Get a builder's display metadata Source: https://docs.struct.to/api-reference/builders/get-a-builders-display-metadata https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/metadata Returns name, website, twitter, icon URL, and description for the given builder code. Returns 200 with `null` body when no metadata has been registered for the code. # Get a single builder's cumulative stats Source: https://docs.struct.to/api-reference/builders/get-a-single-builders-cumulative-stats https://api.struct.to/openapi.json get /polymarket/builders/{builder_code} Returns cumulative stats for one builder over the chosen window. # Get builder fee rate history Source: https://docs.struct.to/api-reference/builders/get-builder-fee-rate-history https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/fees/history Returns the history of rate changes for the given builder code, newest first. # Get builder fee rates Source: https://docs.struct.to/api-reference/builders/get-builder-fee-rates https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/fees Returns the maker and taker fee rates (in basis points) for the given builder code. # List builder metadata Source: https://docs.struct.to/api-reference/builders/list-builder-metadata https://api.struct.to/openapi.json get /polymarket/builders/metadata Paginated list of all builders with registered display metadata, ordered alphabetically by name. # List builders ranked by activity Source: https://docs.struct.to/api-reference/builders/list-builders-ranked-by-activity https://api.struct.to/openapi.json get /polymarket/builders Returns the top builders by the chosen metric over the chosen window. Use `sort` to choose the ranking metric and `timeframe` to choose the window. # Polymarket Builders API Source: https://docs.struct.to/api-reference/builders/overview Builder rankings, fees, attributed activity, and analytics. A builder is an app or integration that routes order flow to Polymarket under a builder code, earning fees on the volume it brings. The Builders endpoints cover rankings and metadata, fees, the traders and tags behind a builder's volume, retention and concentration, and both per-builder and global analytics. ## Discover * [List builders ranked by activity](/api-reference/builders/list-builders-ranked-by-activity), [search by name or code](/api-reference/builders/search-builders-by-name-or-code), or [list builder metadata](/api-reference/builders/list-builder-metadata). * [Get a single builder's cumulative stats](/api-reference/builders/get-a-single-builders-cumulative-stats) and [display metadata](/api-reference/builders/get-a-builders-display-metadata). ## Fees and attribution * Read [builder fee rates](/api-reference/builders/get-builder-fee-rates) and their [history](/api-reference/builders/get-builder-fee-rate-history). * See a builder's [top traders](/api-reference/builders/top-traders-for-a-builder), [per-tag breakdown](/api-reference/builders/per-tag-breakdown-for-a-builder), [trader-volume concentration](/api-reference/builders/trader-volume-concentration-for-a-builder), and [cohort retention](/api-reference/builders/cohort-retention-for-a-builder). * [Bucket the top builders with an "other" composition](/api-reference/builders/bucketed-top-n-with-other-builder-composition). ## Per-builder analytics * [Percentage change](/api-reference/builders/builder-percentage-change-over-a-lookback-window), [per-bucket deltas](/api-reference/builders/builder-analytics-deltas-per-bucket-change), and [cumulative timeseries](/api-reference/builders/builder-analytics-timeseries-cumulative). ## Across all builders * [Cumulative stats](/api-reference/builders/cumulative-stats-across-all-builders), [percentage change](/api-reference/builders/percentage-change-across-all-builders-over-a-lookback-window), [per-bucket deltas](/api-reference/builders/per-bucket-deltas-across-all-builders), and [cumulative timeseries](/api-reference/builders/cumulative-bucket-timeseries-across-all-builders). * [All tags with builder-attributed activity](/api-reference/builders/all-tags-with-builder-attributed-activity), and the [per-builder breakdown for a tag](/api-reference/builders/per-builder-breakdown-for-a-tag). ## Builder-attributed trades * Pull the trades a builder routed by filtering [market trades](/api-reference/market/get-market-trades) on `builder_codes`. ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: builders } = await client.builders.getBuilders({ limit: 25 }); const { data: global } = await client.builders.getGlobal(); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders?limit=25" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guides: [Fetching Trades by Builder Code](/guides/fetching-trades-by-builder-code), [Builder Revenue Dashboard](/guides/builder-revenue-dashboard) * SDK: [REST namespaces](/sdk/rest-api) * [Core concepts](/introduction/concepts) # Per-bucket deltas across all builders Source: https://docs.struct.to/api-reference/builders/per-bucket-deltas-across-all-builders https://api.struct.to/openapi.json get /polymarket/builders/global/analytics/deltas # Per-builder breakdown for a tag Source: https://docs.struct.to/api-reference/builders/per-builder-breakdown-for-a-tag https://api.struct.to/openapi.json get /polymarket/builders/tags/{tag} Returns the top builders that have routed activity into this tag, ranked by the chosen metric over the chosen window. # Per-tag breakdown for a builder Source: https://docs.struct.to/api-reference/builders/per-tag-breakdown-for-a-builder https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/tags Returns the top tags this builder has routed activity into, ranked by the chosen metric over the chosen window. # Percentage change across all builders over a lookback window Source: https://docs.struct.to/api-reference/builders/percentage-change-across-all-builders-over-a-lookback-window https://api.struct.to/openapi.json get /polymarket/builders/global/analytics/changes # Search builders by name or code Source: https://docs.struct.to/api-reference/builders/search-builders-by-name-or-code https://api.struct.to/openapi.json get /polymarket/builders/search Returns builder metadata rows matching the query. Matches against `name` and `builder_code` (the `0x` prefix is optional). Results are sorted alphabetically by name and paginated. # Top traders for a builder Source: https://docs.struct.to/api-reference/builders/top-traders-for-a-builder https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/top-traders Returns the highest-volume (or highest-fee / highest-txn) traders that have routed through this builder over the requested window. Paginate past the first page with `offset` or `pagination_key` to enumerate all of a builder's traders. # Trader-volume concentration for a builder Source: https://docs.struct.to/api-reference/builders/trader-volume-concentration-for-a-builder https://api.struct.to/openapi.json get /polymarket/builders/{builder_code}/concentration How much of the builder's window volume is concentrated in its top traders. Returns total volume + total trader count + top-1/10/100/1k/10k volume and share. # Expand a combo into its legs with market metadata Source: https://docs.struct.to/api-reference/combos/expand-a-combo-into-its-legs-with-market-metadata https://api.struct.to/openapi.json get /polymarket/combos/legs Returns the individual legs that make up a combo (parlay), each with its outcome and market metadata. Pass exactly one of `condition_id` or `position_id`. # Get combo and leg candlesticks Source: https://docs.struct.to/api-reference/combos/get-combo-and-leg-candlesticks https://api.struct.to/openapi.json get /polymarket/combos/candlesticks Returns the selected combo token OHLCV candles plus sampled-price candles for all legs in the combo, up to 50 legs. Pass exactly one of `condition_id` or `position_id`; when using `condition_id`, `outcome_index` defaults to 0 (YES). # Get combo candlesticks Source: https://docs.struct.to/api-reference/combos/get-combo-candlesticks https://api.struct.to/openapi.json get /polymarket/combos/candlestick Returns OHLCV candles for a combo token. Pass exactly one of `condition_id` or `position_id`; when using `condition_id`, `outcome_index` defaults to 0 (YES). # Get combo holder stats Source: https://docs.struct.to/api-reference/combos/get-combo-holder-stats https://api.struct.to/openapi.json get /polymarket/combos/{position_id}/holders/stats Retrieve total holder count and snapshot block for one combo side position from the Polymarket holders engine. Pass the combo row's `yes_position_id` or `no_position_id`, not a leg `position_id` or `condition_id`. # Get combo holders Source: https://docs.struct.to/api-reference/combos/get-combo-holders https://api.struct.to/openapi.json get /polymarket/combos/{position_id}/holders Retrieve holders of one combo side position from the Polymarket holders engine. Pass the combo row's `yes_position_id` or `no_position_id` from `/polymarket/combos` or `/polymarket/combos/metrics`; do not pass a leg `position_id` or `condition_id`. Set `include_pnl=true` to include a per-holder `pnl` object for this combo side. Combo holder state is stored separately from normal position holder state and is sorted by combo position balance descending. # Get combo holders by condition Source: https://docs.struct.to/api-reference/combos/get-combo-holders-by-condition https://api.struct.to/openapi.json get /polymarket/combos/conditions/{condition_id}/holders Retrieve holders for the combo yes/no side position IDs associated with a combo condition ID. The returned position IDs are the values to use as `{position_id}` in `/polymarket/combos/{position_id}/holders`; they are not leg position IDs. Set `include_pnl=true` to include a per-holder `pnl` object for each combo side. # Get combo leg candlesticks Source: https://docs.struct.to/api-reference/combos/get-combo-leg-candlesticks https://api.struct.to/openapi.json get /polymarket/combos/legs/candlestick Returns sampled price candles for one combo leg. Prefer `position_id` from `/polymarket/combos/legs`; alternatively pass `v2_condition_id` with `outcome_index`. # Get combo metrics Source: https://docs.struct.to/api-reference/combos/get-combo-metrics https://api.struct.to/openapi.json get /polymarket/combos/metrics Returns combo details and metrics for one or more timeframes. Supports a single timeframe (for example, `1m`), multiple timeframes (for example, `1m,5m,1h`), or `all` to return every timeframe with data. # Global combo analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/combos/global-combo-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/combos/analytics/deltas Returns per-bucket combo deltas (volume, fees, lifecycle operations that happened *during* each bucket) plus window-unique `unique_traders` / `distinct_combos_traded`. Use for bar-chart visualisations of combo activity over time. # Global combo analytics pct change Source: https://docs.struct.to/api-reference/combos/global-combo-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/combos/analytics/changes Returns % change for each combo metric over the requested lookback window (two cumulative snapshots). Ratio metrics ship as `*_now`/`*_prev` value pairs; `unique_traders` / `distinct_combos_traded` compare window-unique counts of the current vs previous window. `null` fields mean the window predates the available data. # Global combo analytics snapshot Source: https://docs.struct.to/api-reference/combos/global-combo-analytics-snapshot https://api.struct.to/openapi.json get /polymarket/combos/analytics/counts Returns the latest lifetime cumulative combo metrics across all of Polymarket (volume, fees, lifecycle operations, leg-count and YES/NO splits), point-in-time gauges (`current.combos_open` and `current.net_yes_shares_outstanding` are approximations — see field docs), and ratios derived at read time (`derived.*`, null on zero denominators). # Global combo analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/combos/global-combo-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/combos/analytics/timeseries Returns cumulative combo metrics at the end of each bucket. Use for running-total line charts of global combo volume, fees, and lifecycle activity over time. # List combo markets Source: https://docs.struct.to/api-reference/combos/list-combo-markets https://api.struct.to/openapi.json get /polymarket/combos Returns combo (parlay) markets with leg metadata, status, volume, trader activity, prices, and lifecycle statistics. Use `timeframe` to choose the activity window and `pagination_key` from the previous response to request the next page. # Event analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/events/event-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/events/{event_slug}/analytics/deltas Returns per-bucket deltas for a specific event (what happened during each bucket). # Event analytics pct change Source: https://docs.struct.to/api-reference/events/event-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/events/{event_slug}/analytics/changes Per-metric % change over the requested lookback window for a specific event. # Event analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/events/event-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/events/{event_slug}/analytics/timeseries Returns cumulative analytics values for a specific event at the end of each bucket. # Get event by ID or slug Source: https://docs.struct.to/api-reference/events/get-event-by-id-or-slug https://api.struct.to/openapi.json get /polymarket/events/{identifier} Retrieve a single event by its numeric ID or slug with optional nested tags, markets, and metrics # Get event by slug Source: https://docs.struct.to/api-reference/events/get-event-by-slug https://api.struct.to/openapi.json get /polymarket/events/slug/{event_slug} Retrieve a single event by its slug with optional nested tags, markets, and metrics # Get event chart Source: https://docs.struct.to/api-reference/events/get-event-chart https://api.struct.to/openapi.json get /polymarket/events/chart Retrieve price data over time for up to 4 highest-volume markets in an event. Perfect for rendering multi-line charts showing price movement across top markets. TradingView-style: resolution parameter determines both candle size and implicit lookback period. # Get event market outcomes Source: https://docs.struct.to/api-reference/events/get-event-market-outcomes https://api.struct.to/openapi.json get /polymarket/events/outcomes Returns the winning outcome name for each resolved market in an event, keyed by market slug. Useful for quickly checking which outcomes won across a series. # Get event metrics Source: https://docs.struct.to/api-reference/events/get-event-metrics https://api.struct.to/openapi.json get /polymarket/events/metrics Retrieve volume, transaction, and trader metrics for an event. Supports single timeframe (e.g., '1m'), multiple timeframes (e.g., '1m,5m,1h'), or 'all' to get all available timeframes. # Get events Source: https://docs.struct.to/api-reference/events/get-events https://api.struct.to/openapi.json get /polymarket/events Retrieve a paginated list of events with filtering, sorting, and optional nested tags/markets # Polymarket Events API Source: https://docs.struct.to/api-reference/events/overview Event discovery, structure, charts, and per-event analytics on Polymarket. An event groups related markets under a single theme, such as an election or a tournament. Events are identified by an `event_slug` or a numeric id. The Events endpoints cover discovery, the markets and outcomes that make up an event, charts, metrics, and per-event analytics. ## Discover * [List and filter events](/api-reference/events/get-events). * Fetch a single event [by ID or slug](/api-reference/events/get-event-by-id-or-slug) or [by slug](/api-reference/events/get-event-by-slug). ## Structure and metrics * [List an event's market outcomes](/api-reference/events/get-event-market-outcomes). * [Get an event price chart](/api-reference/events/get-event-chart) and [aggregate event metrics](/api-reference/events/get-event-metrics). ## Analytics * Per-event [percentage change](/api-reference/events/event-analytics-pct-change), [per-bucket candles](/api-reference/events/event-analytics-candles-per-bucket-deltas), and [cumulative timeseries](/api-reference/events/event-analytics-timeseries-cumulative). ## Holders and series * See who holds an event's markets over time with [event holders history](/api-reference/holders/get-event-holders-history). * Events can belong to a recurring series: [list or get series](/api-reference/series/list-or-get-series), [a series' events](/api-reference/series/get-series-events), and [series market outcomes](/api-reference/series/get-series-market-outcomes). ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: event } = await client.events.getEventBySlug({ slug: "us-election-2028" }); const { data: metrics } = await client.events.getEventMetrics({ event_slug: "us-election-2028", timeframe: "24h", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/events/slug/us-election-2028" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guide: [Real-Time Trending Events and Markets](/guides/real-time-trending-events-and-markets) * SDK: [Events namespace](/sdk/rest-api#events) * [Core concepts](/introduction/concepts) # Getting Started Source: https://docs.struct.to/api-reference/getting-started Make your first request to the Struct API. ## Base URL All Struct API requests are made to: ``` https://api.struct.to ``` Enterprise customers with a dedicated domain can use their own host. See [SDK Configuration](/sdk/configuration#custom-base-url) for the override. ## Authentication Every request must include your API key in the `X-API-Key` header. ```bash theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: YOUR_API_KEY" ``` See [Authentication](/introduction/authentication) for secret keys, JWT public keys (browser-safe), rotation, and the full error responses. Keep `sk_live_*` keys secret. Never embed them in client-side code, mobile apps, or public repositories. Use `pk_jwt_*` keys for any environment you do not fully control. ## Making your first request Sign up at [struct.to/dashboard](https://struct.to/dashboard) and create an organisation. Open the [API Keys](https://struct.to/dashboard) page in your dashboard and create a new key. Copy the value somewhere safe; you won't be able to view it again. See [Authentication](/introduction/authentication) for key types, JWT public keys, and rotation. Use the base URL and your API key to make your first call: ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: markets } = await client.markets.getMarkets({ limit: 10 }); ``` ```bash cURL theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: YOUR_API_KEY" ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.struct.to/v1/polymarket/market", { headers: { "X-API-Key": "YOUR_API_KEY" }, }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, ) data = response.json() ``` ## Response format Every response is wrapped in a standard envelope with `success`, `data`, `message`, and an `info` block carrying the API `version` and `credits_consumed`. See [Response Format](/api-reference/response-format). ## Rate limits Limits depend on your plan. See [Rate Limits](/guides/rate-limits) for the full matrix and [Pricing](/introduction/pricing) for credit allowances. ## Errors Failures return the same envelope with `success: false`. For the full status-code reference and retry guidance, see [Errors](/guides/errors). # Get event holders history Source: https://docs.struct.to/api-reference/holders/get-event-holders-history https://api.struct.to/openapi.json get /polymarket/holders/events/{event_slug}/history Retrieve historical unique holder counts for an event over a time range. # Get market holders Source: https://docs.struct.to/api-reference/holders/get-market-holders https://api.struct.to/openapi.json get /polymarket/holders/markets Retrieve holders of a market grouped by outcome, sorted by shares held. Identify the market with either `condition_id` or `market_slug` — exactly one must be provided. Set `include_pnl=true` to include a per-holder `pnl` object. Uses cursor-based pagination. # Get market holders history Source: https://docs.struct.to/api-reference/holders/get-market-holders-history https://api.struct.to/openapi.json get /polymarket/holders/markets/history Retrieve historical unique holder counts for a market over a time range. Identify the market with either `condition_id` or `market_slug`. # Get position holders Source: https://docs.struct.to/api-reference/holders/get-position-holders https://api.struct.to/openapi.json get /polymarket/holders/positions/{position_id} Retrieve holders of a specific position (ERC1155 token), sorted by shares held. Set `include_pnl=true` to include a per-holder `pnl` object. Uses cursor-based pagination. # Get position holders history Source: https://docs.struct.to/api-reference/holders/get-position-holders-history https://api.struct.to/openapi.json get /polymarket/holders/positions/{position_id}/history Retrieve historical holder snapshots for a position over a time range. # Polymarket Holders API Source: https://docs.struct.to/api-reference/holders/overview Current and historical holders of markets, positions, and events. Holders are the wallets currently holding a position in a market. The Holders endpoints return the current holder set for a market or a single position, plus historical holder snapshots over time for markets, positions, and events. ## Current holders * [Get the top holders of a market](/api-reference/holders/get-market-holders), or [of a single position](/api-reference/holders/get-position-holders). ## History * Track holder counts and distribution over time for a [market](/api-reference/holders/get-market-holders-history), a [position](/api-reference/holders/get-position-holders-history), or an [event](/api-reference/holders/get-event-holders-history). ## Position context * Pair holder data with live [position metrics](/api-reference/market/get-position-metrics) and [market metrics](/api-reference/market/get-market-metrics), or read the [order book](/api-reference/order-book/get-order-book) for the depth behind a position. ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: holders } = await client.holders.getMarketHolders({ condition_id: "0xabc..." }); const { data: history } = await client.holders.getMarketHoldersHistory({ condition_id: "0xabc..." }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/holders/markets?condition_id=0xabc..." \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guide: [Holder Metrics Tracking](/guides/holder-metrics-tracking) * SDK: [Holders namespace](/sdk/rest-api#holders) * [Core concepts](/introduction/concepts) # Frontend Auth (JWT) Source: https://docs.struct.to/api-reference/jwt-auth Call the Struct API straight from your frontend, authenticated per end user, with no secret key in the browser and no proxy backend to build. JWT public key auth lets your app call the Struct API **directly from the browser or a mobile client**, authenticated as the individual end user, without shipping a secret key and without building a backend of your own to sit in front of Struct. ## The problem it solves Your `sk_*` secret key grants full access to your whole organisation, so it can never ship in a frontend bundle. The usual way around this is to build and operate a proxy backend: it holds the secret key, logs each of your users in, forwards their calls to Struct, and enforces a per-user rate limit so one user can't burn your whole quota. That proxy is real infrastructure to write, host, and pay for, and it adds a network hop to every request. JWT public key auth removes that layer. You create a publishable `pk_jwt_*` key, point it at your auth provider's public keys, and your frontend talks to Struct directly. Struct verifies each end user's JWT, bills your organisation, and applies the per-user rate limit for you. ## What you get * **No secret key in the browser.** The `pk_jwt_*` key is safe to hardcode in a frontend bundle. On its own it does nothing; every request also needs a valid JWT signed by your configured auth provider. * **No proxy to build or run.** Skip the auth-and-forward backend and call Struct directly from the client. Less infrastructure to maintain and host, lower cost. * **Per-user rate limits.** Set a `per_session_rpm` cap that Struct enforces per end user (keyed on the JWT `sub` claim), on top of your parent key's limits, so a single user can't exhaust your quota. * **Any auth provider.** Works with Privy, Auth0, Clerk, Turnkey, Google, or your own issuer, as long as it signs JWTs with an asymmetric key. See [JWT Public Keys](/dashboard/jwt-keys) for how to create and manage keys. ## Wire Format Every request requires two credentials: your `pk_jwt_*` key (hardcoded in your app) and the user's JWT (obtained at login). ### REST ```bash theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-Api-Key: pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" \ -H "Authorization: Bearer " ``` ### WebSocket ``` wss://api.struct.to/ws?api-key=pk_jwt_a1b2c3d4e5f6a1b2c3d4&token= ``` Both parameters are required on every request. ## Auth Flow Request arrives with `X-Api-Key: pk_jwt_xxx` and `Authorization: Bearer `. The `pk_jwt_` prefix triggers the JWT public key auth flow. The key record is loaded from the database (cached 60 seconds). If the key is disabled or not found, the request returns `401`. **JWKS URL keys:** The JWKS is fetched from the configured URL (cached 5 minutes). If the JWT's `kid` is not found in the cached set, the JWKS is re-fetched once to handle key rotation. **Inline public key:** The PEM or JWK JSON key is used directly to verify the JWT signature. Supported algorithms: RS256, RS384, RS512, ES256, ES384, EdDSA. `aud` and `iss` claims are validated if configured on the key. If the JWT is expired or the signature is invalid, the request returns `401`. The `sub` claim is extracted as the session identifier. The parent API key's rate limit and credit cap apply. If `per_session_rpm` is set, an additional per-user rate limit is checked (keyed on `pk_jwt_xxx:sub`). Credits are billed through the parent API key. ## Supported Algorithms Only **asymmetric** JWT signing algorithms are supported: | Family | Algorithms | | -------------- | ------------------- | | RSA | RS256, RS384, RS512 | | Elliptic Curve | ES256, ES384 | | Edwards Curve | EdDSA | Symmetric algorithms (HS256, HS384, HS512) are **not supported**. JWTs signed with shared secrets will be rejected. Your auth provider must use asymmetric key pairs where the public key is exposed via a JWKS endpoint or provided inline. ## Key Verification Methods ### JWKS URL Provide a JWKS endpoint URL and Struct will fetch the public keys automatically. This is the standard approach for most auth providers. The JWKS is cached for 5 minutes, and if a JWT arrives with an unknown `kid`, the JWKS is re-fetched once to handle key rotation. ### Inline Public Key Alternatively, provide the public key directly in PEM format (`-----BEGIN PUBLIC KEY-----`) or as JWK JSON (`{"kty":"RSA", ...}`). The key is used directly to verify JWT signatures without any external fetch. # Detect price jumps Source: https://docs.struct.to/api-reference/market/detect-price-jumps https://api.struct.to/openapi.json get /polymarket/market/price-jumps Scan candles for significant price movements. Returns jumps with from/to timestamps in milliseconds, directly usable as trades API time range parameters to identify traders who traded before or during the movement. # Get market by condition ID Source: https://docs.struct.to/api-reference/market/get-market-by-condition-id https://api.struct.to/openapi.json get /polymarket/market/{condition_id} Retrieve one or more markets by condition ID. Supports batch lookups via query params. Returns an array of MarketResponse objects. # Get market by slug Source: https://docs.struct.to/api-reference/market/get-market-by-slug https://api.struct.to/openapi.json get /polymarket/market/slug/{market_slug} Retrieve one or more markets by slug. Supports batch lookups via query params. Returns an array of MarketResponse objects. # Get market candlesticks by condition_id Source: https://docs.struct.to/api-reference/market/get-market-candlesticks-by-condition_id https://api.struct.to/openapi.json get /polymarket/market/candlestick Retrieve OHLCV candlestick data for a market by its condition_id # Get market chart Source: https://docs.struct.to/api-reference/market/get-market-chart https://api.struct.to/openapi.json get /polymarket/market/chart Retrieve price data over time for up to 4 position outcomes in a market condition. TradingView-style: resolution parameter determines both candle size and implicit lookback period. Auto-selects the 4 most active outcomes if position_ids not specified. # Get market metrics Source: https://docs.struct.to/api-reference/market/get-market-metrics https://api.struct.to/openapi.json get /polymarket/market/metrics Retrieve volume, transaction, and trader metrics for a market. Supports single timeframe (e.g., '1m'), multiple timeframes (e.g., '1m,5m,1h'), or 'all' to get all available timeframes. # Get market trades Source: https://docs.struct.to/api-reference/market/get-market-trades https://api.struct.to/openapi.json get /polymarket/market/trades Retrieve trades for one or more markets, with filtering by trader, side, price, amount, and time range # Get market volume chart Source: https://docs.struct.to/api-reference/market/get-market-volume-chart https://api.struct.to/openapi.json get /polymarket/market/volume-chart Retrieve volume breakdown by YES/NO outcome over time for a prediction market # Get markets Source: https://docs.struct.to/api-reference/market/get-markets https://api.struct.to/openapi.json get /polymarket/market Retrieve a paginated list of markets with filtering, sorting, and optional nested tags/events/metrics # Get position candlesticks by position_id Source: https://docs.struct.to/api-reference/market/get-position-candlesticks-by-position_id https://api.struct.to/openapi.json get /polymarket/market/position/candlestick Retrieve OHLCV candlestick data for a specific position by its position_id # Get position metrics Source: https://docs.struct.to/api-reference/market/get-position-metrics https://api.struct.to/openapi.json get /polymarket/market/position/metrics Retrieve volume, transaction, and trader metrics for a specific position. Supports single timeframe (e.g., '1m'), multiple timeframes (e.g., '1m,5m,1h'), or 'all' to get all available timeframes. # Get position volume chart Source: https://docs.struct.to/api-reference/market/get-position-volume-chart https://api.struct.to/openapi.json get /polymarket/market/position/volume-chart Retrieve volume over time for a specific position with buy/sell breakdown # Get top traders for a market Source: https://docs.struct.to/api-reference/market/get-top-traders-for-a-market https://api.struct.to/openapi.json get /polymarket/market/top-traders Top traders for a market, ranked by total PnL desc. # Get top traders for a position Source: https://docs.struct.to/api-reference/market/get-top-traders-for-a-position https://api.struct.to/openapi.json get /polymarket/market/position/top-traders Top traders for a position, ranked by total PnL desc. # List oracle events Source: https://docs.struct.to/api-reference/market/list-oracle-events https://api.struct.to/openapi.json get /polymarket/market/oracle-events Retrieve on-chain oracle lifecycle events (resolutions, disputes, assertions, emergency resolutions). Filter by `condition_ids` or `event_types` and paginate with `limit`/`pagination_key`. # Market analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/market/market-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/market/{condition_id}/analytics/deltas Returns per-bucket deltas for a specific market (what happened during each bucket). # Market analytics pct change Source: https://docs.struct.to/api-reference/market/market-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/market/{condition_id}/analytics/changes Per-metric % change over the requested lookback window for a specific market. # Market analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/market/market-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/market/{condition_id}/analytics/timeseries Returns cumulative analytics values for a specific market at the end of each bucket. # Polymarket Markets API Source: https://docs.struct.to/api-reference/market/overview Market discovery, pricing, trades, and per-market analytics on Polymarket. A market is a single tradable question on Polymarket, identified by its `condition_id`. Each market resolves into one or more outcome positions, and every position has its own `position_id`. The Market endpoints cover discovery, metadata, price and volume history, candlesticks, trades, top traders, and per-market analytics. ## Discover * [List and filter markets](/api-reference/market/get-markets) by tag, status, or closed state. * Fetch a single market [by condition ID](/api-reference/market/get-market-by-condition-id) or [by slug](/api-reference/market/get-market-by-slug). ## Price and volume * [Get a market price chart](/api-reference/market/get-market-chart) or [OHLC candlesticks](/api-reference/market/get-market-candlesticks-by-condition_id). * [Chart market volume over time](/api-reference/market/get-market-volume-chart). * Drill into a single outcome with [position candlesticks](/api-reference/market/get-position-candlesticks-by-position_id) and the [position volume chart](/api-reference/market/get-position-volume-chart). ## Activity and metrics * [Page through a market's trades](/api-reference/market/get-market-trades). * [Detect sudden price jumps](/api-reference/market/detect-price-jumps) across markets. * Surface top traders [for a market](/api-reference/market/get-top-traders-for-a-market) or [for a position](/api-reference/market/get-top-traders-for-a-position). * Read live [market metrics](/api-reference/market/get-market-metrics) and [position metrics](/api-reference/market/get-position-metrics). * [List oracle events](/api-reference/market/list-oracle-events) for resolution tracking. ## Analytics * Per-market [percentage change](/api-reference/market/market-analytics-pct-change), [per-bucket candles](/api-reference/market/market-analytics-candles-per-bucket-deltas), and [cumulative timeseries](/api-reference/market/market-analytics-timeseries-cumulative). ## Order book and holders * See live CLOB depth with the [order book for a position](/api-reference/order-book/get-order-book) or [every book in a market](/api-reference/order-book/get-order-books-for-a-market), and track [spread history](/api-reference/order-book/get-spread-history). * See who holds a market with [market holders](/api-reference/holders/get-market-holders) and their [history over time](/api-reference/holders/get-market-holders-history). * View markets through a fixed-income lens with [bonds](/api-reference/bonds/get-bonds). ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: markets } = await client.markets.getMarkets({ limit: 10, tags: "politics" }); const { data: market } = await client.markets.getMarket({ conditionId: "0xabc..." }); const { data: candles } = await client.markets.getCandlestick({ condition_id: "0xabc...", resolution: "1h", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market?limit=10&tags=politics" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guides: [Finding Market Movers](/guides/finding-market-movers), [TradingView Charts](/guides/tradingview-charts), [CLOB Liquidity Markets Dashboard](/guides/clob-liquidity-markets-dashboard) * SDK: [Markets namespace](/sdk/rest-api#markets) * [Core concepts](/introduction/concepts) # Get order book Source: https://docs.struct.to/api-reference/order-book/get-order-book https://api.struct.to/openapi.json get /polymarket/order-book Returns the latest CLOB orderbook snapshot for a position, including derived metrics (best bid/ask, mid price, spread, liquidity depth). Data is sourced from the real-time Polymarket WebSocket feed. `bids` and `asks` are arrays of `{"p": price, "s": size}` objects, sorted best-first. # Get order book history Source: https://docs.struct.to/api-reference/order-book/get-order-book-history https://api.struct.to/openapi.json get /polymarket/order-book/history Paginated history of raw CLOB orderbook snapshots including full bids/asks levels and derived metrics. Default limit 20, max 200. `bids` and `asks` are arrays of `{"p": price, "s": size}` objects, sorted best-first. # Get order books for a market Source: https://docs.struct.to/api-reference/order-book/get-order-books-for-a-market https://api.struct.to/openapi.json get /polymarket/order-book/market Returns the latest orderbook snapshot for every position (outcome) in a market. Accepts condition_id or market_slug. `bids` and `asks` are arrays of `{"p": price, "s": size}` objects, sorted best-first. # Get spread history Source: https://docs.struct.to/api-reference/order-book/get-spread-history https://api.struct.to/openapi.json get /polymarket/order-book/spread Lightweight time series of derived orderbook metrics (best bid/ask, mid price, spread, liquidity depth) without raw bids/asks — ideal for charting. Default limit 20, max 200. # Polymarket Order Book API Source: https://docs.struct.to/api-reference/order-book/overview Live CLOB depth and historical spread for Polymarket positions. The Order Book endpoints expose central limit order book (CLOB) depth for Polymarket positions: the current bids and asks for a position, the order books across a whole market, and historical depth and spread. ## Live depth * [Get the live order book for a position](/api-reference/order-book/get-order-book), or [every order book in a market](/api-reference/order-book/get-order-books-for-a-market). ## History * Track depth and spread over time with [order book history](/api-reference/order-book/get-order-book-history) and [spread history](/api-reference/order-book/get-spread-history). ## Market context * Resolve the market behind a book with [get a market by condition ID](/api-reference/market/get-market-by-condition-id), read its [position metrics](/api-reference/market/get-position-metrics), or see the [holders of a position](/api-reference/holders/get-position-holders). ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: book } = await client.orderBook.getOrderBook({ position_id: "0xabc..." }); const { data: spread } = await client.orderBook.getSpreadHistory({ position_id: "0xabc..." }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/order-book?position_id=0xabc..." \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guide: [CLOB Liquidity Markets Dashboard](/guides/clob-liquidity-markets-dashboard) * SDK: [Order Book namespace](/sdk/rest-api#order-book) * [Core concepts](/introduction/concepts) # Pagination Source: https://docs.struct.to/api-reference/pagination Page through list endpoints with cursor or offset pagination. List endpoints return a single page of results plus a `pagination` block on the [response envelope](/api-reference/response-format): ```json theme={null} { "data": [ ... ], "pagination": { "has_more": true, "pagination_key": "abc123" } } ``` Two strategies are available for walking through pages: cursor pagination with `pagination_key`, and offset pagination with `offset`. ## Cursor pagination (recommended) Pass the `pagination_key` from the previous response as a query parameter in your next request. Keep going until `has_more` is `false` or `pagination_key` is `null`. Cursor pagination stays fast no matter how deep you go, because the server resumes from the cursor instead of counting past the rows you skipped. **Use it for any programmatic consumer**: data syncs, backfills, agents, and background jobs. ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market?pagination_key=abc123" \ -H "X-API-Key: YOUR_API_KEY" ``` ```javascript JavaScript theme={null} let paginationKey = null; do { const params = new URLSearchParams(); if (paginationKey) params.set("pagination_key", paginationKey); const res = await fetch(`https://api.struct.to/v1/polymarket/market?${params}`, { headers: { "X-API-Key": "YOUR_API_KEY" }, }); const json = await res.json(); console.log(json.data); paginationKey = json.pagination?.pagination_key ?? null; } while (paginationKey); ``` ```python Python theme={null} import requests pagination_key = None while True: params = {} if pagination_key: params["pagination_key"] = pagination_key response = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, params=params, ) json = response.json() print(json["data"]) pagination = json.get("pagination") if not pagination or not pagination.get("has_more"): break pagination_key = pagination["pagination_key"] ``` ## Offset pagination Some endpoints (currently the trader trades and PnL endpoints) also accept an `offset` query parameter, which skips a fixed number of rows from the start of the result set. Offset exists for one specific case: **server-rendered, deep-linkable pages**. An SSR page at `?page=42` can map directly to `offset=4100&limit=100` and render that slice on the first request, without first replaying every page to obtain a cursor. ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/trades/0xTRADER?limit=100&offset=4100" \ -H "X-API-Key: YOUR_API_KEY" ``` `offset` is capped at a few thousand rows (typically in the 3,000 to 5,000 range). To read past that point, or to walk the whole dataset, switch to `pagination_key`. When both `offset` and `pagination_key` are supplied on the same request, `offset` takes precedence and the cursor is ignored. ## Which one to use | | Cursor (`pagination_key`) | Offset (`offset`) | | ------------------------- | ------------------------------------- | ----------------------------------------- | | Fetch the entire dataset | Yes | Not recommended | | Jump to an arbitrary page | No | Yes | | Best for | Programmatic consumers, syncs, agents | SSR pages with deep-linkable page numbers | Default to `pagination_key`. Reach for `offset` only when you need to render an arbitrary page directly from a URL, and even then prefer the cursor once a user is paging sequentially. Using the TypeScript SDK? The [`paginate` helper](/sdk/pagination) handles cursor iteration for you. **Related guide:** [Fetching an entire dataset](/guides/fetching-all-data) walks through paging every row with the cursor for a complete backfill. # Response Format Source: https://docs.struct.to/api-reference/response-format Understand how all API responses are structured. Every response from the Struct API is wrapped in a standard envelope. This structure is consistent across all endpoints, regardless of the data being returned. ## Envelope Structure ```json theme={null} { "success": true, "data": { ... }, "message": null, "info": { "version": "1.0.0", "credits_consumed": 1 }, "pagination": { "has_more": true, "pagination_key": "abc123" } } ``` The `pagination` field is optional and only appears on list endpoints. ## Fields ### `success` A boolean indicating whether the request was processed successfully. ### `data` The response payload. Its type depends on the endpoint; it can be a single object or an array. ### `message` A human-readable status message. Typically `null` on success. On errors, this contains a description of what went wrong. ### `info` Metadata about the request execution. Present on most successful responses. | Field | Type | Description | | ------------------ | -------- | --------------------------------------------- | | `version` | `string` | API version that served the request | | `credits_consumed` | `number` | Number of API credits consumed by the request | ### `pagination` Included on list endpoints that support cursor-based pagination. | Field | Type | Description | | ---------------- | -------------------------- | ----------------------------------------------------------- | | `has_more` | `boolean` | Whether additional pages of results exist | | `pagination_key` | `string \| number \| null` | Pass this value as a query parameter to fetch the next page | ## Pagination List endpoints return paginated results. To iterate through all pages, pass the `pagination_key` from the previous response as a query parameter in your next request. Some endpoints also accept an `offset` parameter for deep-linkable server-rendered pages. See [Pagination](/api-reference/pagination) for the full walkthrough and when to use each strategy. ## Error Responses When a request fails, the envelope still applies. The `success` field will be `false` and `message` will describe the error. ```json theme={null} { "success": false, "data": null, "message": "Invalid API key" } ``` # Search events, markets, traders, and builders Source: https://docs.struct.to/api-reference/search/search-events-markets-traders-and-builders https://api.struct.to/openapi.json get /polymarket/search Search across markets, events, traders, and builders. Use `type` to limit which categories are searched. Event and market search support exact slug lookup when `q` is slug-shaped. Trader search supports wallet address lookup or name search; builder search matches against builder name and builder_code. Results for each category are independently paginated. Only requested categories are included in the response. # Get series events Source: https://docs.struct.to/api-reference/series/get-series-events https://api.struct.to/openapi.json get /polymarket/series/{identifier}/events Returns a paginated list of events belonging to a specific Polymarket series, with full market data for each event. # Get series market outcomes Source: https://docs.struct.to/api-reference/series/get-series-market-outcomes https://api.struct.to/openapi.json get /polymarket/series/outcomes Returns the winning outcome name for each resolved market across all events in a series, keyed by market slug. Useful for checking historical results across recurring series (e.g., btc-updown-5m). # List or get series Source: https://docs.struct.to/api-reference/series/list-or-get-series https://api.struct.to/openapi.json get /polymarket/series Retrieve series. Use `id` or `series_slug` for single lookup, `series_ids` or `series_slugs` (comma-separated, max 250, mutually exclusive) for multi-lookup, or paginate with `active_only`. # Get tag by slug Source: https://docs.struct.to/api-reference/tags/get-tag-by-slug https://api.struct.to/openapi.json get /polymarket/tags/{identifier} Retrieve a single tag by its ID or slug. Pass `include_metrics=true` to attach analytics metrics (volume_usd, shares_volume, txn_count, unique_traders, unique_makers, unique_takers, fees_usd, and builder metrics). Use `timeframe` to select the window; defaults to `1d`. # Get tags Source: https://docs.struct.to/api-reference/tags/get-tags https://api.struct.to/openapi.json get /polymarket/tags Retrieve all available event tags/categories. Default listing is alphabetical (cursor-paginated). If either `sort` or `timeframe` is provided, the listing is ranked by the chosen metric+window (offset-paginated): sort defaults to `volume` when only `timeframe` is set, timeframe defaults to `24h` when only `sort` is set. # Get top traders for a category Source: https://docs.struct.to/api-reference/tags/get-top-traders-for-a-category https://api.struct.to/openapi.json get /polymarket/tags/top-traders Top traders for a category. Default ranking is total PnL desc. # Polymarket Tags API Source: https://docs.struct.to/api-reference/tags/overview Browse market categories, category leaders, and per-tag analytics. A tag is a category that groups markets and events by theme, such as politics, crypto, or sports. The Tags endpoints list the available tags, fetch a single tag, surface the top traders within a category, and expose per-tag analytics. ## Browse * [List every tag](/api-reference/tags/get-tags), or [fetch a single tag by slug](/api-reference/tags/get-tag-by-slug). ## Category leaders * [Find the top traders within a category](/api-reference/tags/get-top-traders-for-a-category). ## Analytics * Per-tag [percentage change](/api-reference/tags/tag-analytics-pct-change), [per-bucket candles](/api-reference/tags/tag-analytics-candles-per-bucket-deltas), and [cumulative timeseries](/api-reference/tags/tag-analytics-timeseries-cumulative). ## Filter by tag * Use a tag to filter [markets](/api-reference/market/get-markets) and [events](/api-reference/events/get-events) down to a category. ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const { data: tags } = await client.tags.getTags(); const { data: tag } = await client.tags.getTag({ identifier: "politics" }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/tags" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * SDK: [Tags namespace](/sdk/rest-api#tags) * [Core concepts](/introduction/concepts) # Tag analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/tags/tag-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/tags/{tag}/analytics/deltas Returns per-bucket deltas for a specific tag (what happened during each bucket). # Tag analytics pct change Source: https://docs.struct.to/api-reference/tags/tag-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/tags/{tag}/analytics/changes Per-metric % change over the requested lookback window for a specific tag. # Tag analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/tags/tag-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/tags/{tag}/analytics/timeseries Returns cumulative analytics values for a specific tag at the end of each bucket. # Batch trader PnL summaries Source: https://docs.struct.to/api-reference/trader/batch-trader-pnl-summaries https://api.struct.to/openapi.json post /polymarket/trader/pnl/batch PnL summaries for many wallets across many timeframes in one call, keyed by wallet then timeframe (a wallet/timeframe with no data is omitted). # Combo position with legs, metadata, and PnL Source: https://docs.struct.to/api-reference/trader/combo-position-with-legs-metadata-and-pnl https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/combo Expands one combo (parlay) position into its legs — each with market metadata, live price, and won/lost/pending status — together with the trader's PnL row for the combo and derived figures (implied probability, potential payout, dead flag). # Get global PnL leaderboard Source: https://docs.struct.to/api-reference/trader/get-global-pnl-leaderboard https://api.struct.to/openapi.json get /polymarket/trader/global_pnl Trader leaderboard including combo fields. # Get multiple trader profiles Source: https://docs.struct.to/api-reference/trader/get-multiple-trader-profiles https://api.struct.to/openapi.json get /polymarket/trader/profiles/batch Retrieve profiles for multiple traders in a single request. Returns an array of profiles. # Get top markets across all traders Source: https://docs.struct.to/api-reference/trader/get-top-markets-across-all-traders https://api.struct.to/openapi.json get /polymarket/trader/top_trades_markets Top markets across all traders, ranked by total PnL desc. # Get trader best and worst PnL periods Source: https://docs.struct.to/api-reference/trader/get-trader-best-and-worst-pnl-periods https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/periods Best and worst daily, weekly and monthly PnL and portfolio periods. # Get trader category PnL Source: https://docs.struct.to/api-reference/trader/get-trader-category-pnl https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/categories Per-category PnL for a trader. # Get trader category PnL candles Source: https://docs.struct.to/api-reference/trader/get-trader-category-pnl-candles https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/category-candles Per-category PnL candles for a trader. Same shape as `/candles`, plus a required `category`. # Get trader market PnL Source: https://docs.struct.to/api-reference/trader/get-trader-market-pnl https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/markets Per-market PnL for a trader ; rows carry `combo_type` when the market is a combo. # Get trader overview Source: https://docs.struct.to/api-reference/trader/get-trader-overview https://api.struct.to/openapi.json get /polymarket/trader/profile/{address} Retrieve a trader's profile including stats and trading history summary # Get trader PnL calendar Source: https://docs.struct.to/api-reference/trader/get-trader-pnl-calendar https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/calendar Retrieve daily PnL candles for a trader. # Get trader PnL candles Source: https://docs.struct.to/api-reference/trader/get-trader-pnl-candles https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/candles PnL candles for a trader. # Get trader PnL changes Source: https://docs.struct.to/api-reference/trader/get-trader-pnl-changes https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/changes PnL, portfolio, balance, and open-position changes per timeframe. # Get trader PnL risk Source: https://docs.struct.to/api-reference/trader/get-trader-pnl-risk https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/risk Max drawdown, current drawdown, max runup, high, low and latest values. # Get trader PnL summary Source: https://docs.struct.to/api-reference/trader/get-trader-pnl-summary https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address} Trader PnL summary including combo fields. # Get trader position exits Source: https://docs.struct.to/api-reference/trader/get-trader-position-exits https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/exits Position exit markers for a trader. # Get trader position PnL Source: https://docs.struct.to/api-reference/trader/get-trader-position-pnl https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/positions Per-position PnL for a trader. `status` is required — every position lookup is scoped to either currently-open positions or closed/resolved ones. `sort_by` and `search` can only be combined with a status; the per-status sort-by whitelists are exposed as `PositionOpenPnlSortBy` and `PositionClosedPnlSortBy`. # Get trader trades Source: https://docs.struct.to/api-reference/trader/get-trader-trades https://api.struct.to/openapi.json get /polymarket/trader/trades/{address} Retrieve trade history for a specific trader across all markets # Get trader volume chart Source: https://docs.struct.to/api-reference/trader/get-trader-volume-chart https://api.struct.to/openapi.json get /polymarket/trader/volume-chart/{address} Retrieve volume breakdown by buy/sell over time for a specific trader # List combo positions with legs, metadata, and PnL Source: https://docs.struct.to/api-reference/trader/list-combo-positions-with-legs-metadata-and-pnl https://api.struct.to/openapi.json get /polymarket/trader/pnl/{address}/combos Lists a trader's combos (parlays), one entry per combo, each expanded into its legs with market metadata, live prices, won/lost/pending status, the trader's PnL row, and derived figures (implied probability, potential payout, dead flag). Supports title search and combo-level sorting. # Polymarket Traders API Source: https://docs.struct.to/api-reference/trader/overview Trader profiles, trade history, volume, and the full PnL suite. A trader is a wallet address active on Polymarket, identified by its on-chain `address`. The Trader endpoints cover profiles, trade history, volume, and a deep PnL suite (summary, by market, by category, combo/parlay, candles, calendar, exits, and risk), plus the global PnL leaderboard. Every PnL figure Struct returns is true net PnL: realized and unrealized combined, with trading fees, maker rebates, rewards, and yield all factored in. The leaderboard and a single wallet's summary share the same accounting, so the numbers always agree. ## Profiles and activity * [Get a trader overview](/api-reference/trader/get-trader-overview) for a single wallet, or [fetch multiple profiles in one call](/api-reference/trader/get-multiple-trader-profiles). * [Page through a trader's trades](/api-reference/trader/get-trader-trades) and [chart their volume over time](/api-reference/trader/get-trader-volume-chart). ## PnL * [Fetch open and closed positions with full PnL stats](/api-reference/trader/get-trader-position-pnl), including realized and unrealized returns per position. Position and market rows carry `combo_type` (`combinatorial`) when the market is a combo; filter with the `combo` query param. * [Read a wallet's PnL summary](/api-reference/trader/get-trader-pnl-summary), then break it down [by market](/api-reference/trader/get-trader-market-pnl) or [by category](/api-reference/trader/get-trader-category-pnl). Summaries include `combo_trade_count`. * Expand multi-leg parlays with [combo PnL listing](/api-reference/trader/list-combo-positions-with-legs-metadata-and-pnl) and [single-combo breakdown](/api-reference/trader/combo-position-with-legs-metadata-and-pnl) — legs, implied probability, and potential payout. See [Tracking a trader's combo PnL](/guides/trader-combo-pnl). * Chart performance with [PnL candles](/api-reference/trader/get-trader-pnl-candles), a [PnL calendar heatmap](/api-reference/trader/get-trader-pnl-calendar), and [period-over-period changes](/api-reference/trader/get-trader-pnl-changes). * Surface [position exits](/api-reference/trader/get-trader-position-exits), [best and worst periods](/api-reference/trader/get-trader-best-and-worst-pnl-periods), and [risk metrics](/api-reference/trader/get-trader-pnl-risk). ## Leaderboards Struct ranks traders on **true net PnL**, not trade volume or realized gains alone. Each entry combines realized and unrealized PnL and accounts for trading fees, maker rebates, rewards, and yield, so the board reflects what a trader has actually earned. * [Rank traders by global PnL](/api-reference/trader/get-global-pnl-leaderboard). Scope to any `timeframe` (lifetime by default), order the board with `sort_by` (default `total_pnl_usd`; includes `combo_trade_count`) and `sort_direction`, and page through it with the cursor (`limit` up to 500). * Narrow the board to a scope: [top traders by category](/api-reference/tags/get-top-traders-for-a-category), [in a single market](/api-reference/market/get-top-traders-for-a-market), [holding a specific position](/api-reference/market/get-top-traders-for-a-position), or [for a builder](/api-reference/builders/top-traders-for-a-builder). * [Find the top markets across all traders](/api-reference/trader/get-top-markets-across-all-traders) to see where that PnL is being made. Every leaderboard row carries the full breakdown (realized, unrealized, fees, maker rebates, rewards, and yield) alongside win rate, profit factor, average win and loss, and best and worst trades. It is the same shape returned by the [per-trader PnL summary](/api-reference/trader/get-trader-pnl-summary), so a wallet's standing on the board and its own profile always agree. ## Analytics * Per-trader [percentage change](/api-reference/trader/trader-analytics-pct-change), [per-bucket candles](/api-reference/trader/trader-analytics-candles-per-bucket-deltas), and [cumulative timeseries](/api-reference/trader/trader-analytics-timeseries-cumulative). ## Example ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; const { data: profile } = await client.trader.getTraderProfile({ address }); const { data: pnl } = await client.trader.getTraderPnl({ address }); const { data: leaderboard } = await client.trader.getGlobalPnl({ limit: 100 }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/profile/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Related * Guides: [Trader Open and Closed Positions](/guides/trader-open-and-closed-positions), [Combo (parlay) PnL](/guides/trader-combo-pnl), [Live Trader PnL Dashboard](/guides/live-trader-pnl-dashboard), [Trader PnL History Charts](/guides/trader-pnl-history-charts), [Copy Trading](/guides/copy-trading-pending-or-confirmed-trades) * SDK: [Trader namespace](/sdk/rest-api#trader) * [Core concepts](/introduction/concepts) # Trader analytics candles (per-bucket deltas) Source: https://docs.struct.to/api-reference/trader/trader-analytics-candles-per-bucket-deltas https://api.struct.to/openapi.json get /polymarket/trader/{address}/analytics/deltas Returns per-bucket deltas for a specific trader address (what happened during each bucket). # Trader analytics pct change Source: https://docs.struct.to/api-reference/trader/trader-analytics-pct-change https://api.struct.to/openapi.json get /polymarket/trader/{address}/analytics/changes Per-metric % change over the requested lookback window for a specific trader address. # Trader analytics timeseries (cumulative) Source: https://docs.struct.to/api-reference/trader/trader-analytics-timeseries-cumulative https://api.struct.to/openapi.json get /polymarket/trader/{address}/analytics/timeseries Returns cumulative analytics values for a specific trader address at the end of each bucket. # Asset price tick callback Source: https://docs.struct.to/api-reference/webhook-callbacks/asset-price-tick-callback https://api.struct.to/webhookopenapi.json webhook asset-price-tick Fired when the price of a tracked crypto asset updates (BTC, ETH, SOL, XRP, DOGE, BNB, HYPE). Use `asset_symbols` to restrict to specific assets (empty = all). # Asset price window update callback Source: https://docs.struct.to/api-reference/webhook-callbacks/asset-price-window-update-callback https://api.struct.to/webhookopenapi.json webhook asset-price-window-update Fired at the start and end of each price candle for tracked crypto assets (BTC, ETH, SOL, XRP, DOGE, BNB, HYPE). Payload includes `update_type` (`"open"` or `"close"`) indicating whether the candle is opening or closing. Use `asset_symbols` to restrict to specific assets. Use `timeframes` to restrict to specific candle sizes — valid values: `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"`, `"24h"`. # Category pnl callback Source: https://docs.struct.to/api-reference/webhook-callbacks/category-pnl-callback https://api.struct.to/webhookopenapi.json webhook trader-category-pnl Fired when a trader's category-level PnL crosses a configured threshold # Close to bond callback Source: https://docs.struct.to/api-reference/webhook-callbacks/close-to-bond-callback https://api.struct.to/webhookopenapi.json webhook close-to-bond Fired when a trade occurs at a near-certain-outcome price. The alert matches on the traded position's own price — so you receive the trade for the exact `position_id` that hit the threshold. **At least one of `min_price` or `max_price` is required.** Use `min_price` (e.g. `0.95`) to trigger when the traded outcome is near-certain (price ≥ threshold); use `max_price` (e.g. `0.05`) for the near-zero side (price ≤ threshold). Optional filters: `position_outcome_indices` — restrict by outcome index (`0` = Yes/Up, `1` = No); `condition_ids` — restrict to specific markets; `position_ids` — restrict to specific outcome tokens; `outcomes` — restrict by outcome name (e.g. `"Yes"`, `"No"`); `event_slugs` — restrict to specific events. Scope by market taxonomy with `tags` (matches a market's tags or category) and `series_slugs` (matches its parent series). # Condition holder metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/condition-holder-metrics-callback https://api.struct.to/webhookopenapi.json webhook condition-holder-metrics Fired when holder metrics change for a condition. # Event holder metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/event-holder-metrics-callback https://api.struct.to/webhookopenapi.json webhook event-holder-metrics Fired when holder metrics change for an event. # Event liquidity threshold callback Source: https://docs.struct.to/api-reference/webhook-callbacks/event-liquidity-threshold-callback https://api.struct.to/webhookopenapi.json webhook event-liquidity Fired when an event's total USD order-book liquidity (summed across its markets) crosses a threshold. **`event_slugs` is required**, plus at least one of `min_liquidity_usd` (fires when liquidity crosses **up** through the value) or `max_liquidity_usd` (fires when it crosses **down**). Thresholds are edge-triggered and re-arm once liquidity crosses back, unless `one_shot` is set (fire once, then delete). # Event metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/event-metrics-callback https://api.struct.to/webhookopenapi.json webhook event-metrics Fired when an event's volume or transaction metrics cross a configured threshold. Use `timeframes` to restrict to specific windows (valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`). # Event volume milestone callback Source: https://docs.struct.to/api-reference/webhook-callbacks/event-volume-milestone-callback https://api.struct.to/webhookopenapi.json webhook event-volume-milestone Fired when an event's trading volume crosses a milestone threshold in the specified timeframe. **`timeframes` is required** (e.g. `["1h", "24h"]`) — valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. Optional `milestone_amounts` restricts to specific USD thresholds. Optional `event_slugs` restricts to specific events. # Event volume spike callback Source: https://docs.struct.to/api-reference/webhook-callbacks/event-volume-spike-callback https://api.struct.to/webhookopenapi.json webhook event-volume-spike Fired when an event's aggregated trading volume grows by a multiple of `spike_ratio` within a timeframe. Requires `spike_ratio` (> 1.0, e.g. `2.0` fires when volume doubles). Optional `window_secs` sets the observation window in seconds (max 600). Optional `timeframes` and `event_slugs` to narrow scope. # Exit markers callback Source: https://docs.struct.to/api-reference/webhook-callbacks/exit-markers-callback https://api.struct.to/webhookopenapi.json webhook trader-pnl-exits Fired once per position close, labelled with the exit reason (resolved_win / resolved_loss / sold_win / sold_loss). # First trade callback Source: https://docs.struct.to/api-reference/webhook-callbacks/first-trade-callback https://api.struct.to/webhookopenapi.json webhook trader-first-trade Fired when a tracked trader executes their first trade on Polymarket # Global pnl callback Source: https://docs.struct.to/api-reference/webhook-callbacks/global-pnl-callback https://api.struct.to/webhookopenapi.json webhook trader-global-pnl Fired when a trader's global PnL crosses a configured threshold # Market created callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-created-callback https://api.struct.to/webhookopenapi.json webhook market-created Fired when a new prediction market is created on Polymarket. Filterable by `tags` and `event_slugs`. # Market disputed callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-disputed-callback https://api.struct.to/webhookopenapi.json webhook market-disputed Fired when a proposed market resolution is challenged on-chain (`DisputePrice` or `AssertionDisputed`) — the resolution is now contested and delayed. Carries the disputer, the proposer, and the disputed/proposed outcome when available. Optional filters: `condition_ids`, `event_slugs`, `exclude_shortterm_markets`. # Market liquidity threshold callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-liquidity-threshold-callback https://api.struct.to/webhookopenapi.json webhook market-liquidity Fired when a market's total USD order-book liquidity crosses a threshold. **`condition_ids` is required**, plus at least one of `min_liquidity_usd` (fires when liquidity crosses **up** through the value) or `max_liquidity_usd` (fires when it crosses **down**). Thresholds are edge-triggered and re-arm once liquidity crosses back, unless `one_shot` is set (fire once, then delete). # Market metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-metrics-callback https://api.struct.to/webhookopenapi.json webhook market-metrics Fired when a market's volume or transaction metrics cross a configured threshold. Use `timeframes` to restrict to specific windows (valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`). # Market pnl callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-pnl-callback https://api.struct.to/webhookopenapi.json webhook trader-market-pnl Fired when a trader's market-level PnL crosses a configured threshold # Market resolved callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-resolved-callback https://api.struct.to/webhookopenapi.json webhook market-resolved Fired once when a market reaches a terminal resolution. Collapses the underlying oracle events (`QuestionResolved`, `QuestionEmergencyResolved`, `ConditionResolution`, `NegRiskOutcomeReported`) into a single notification carrying the winning outcome and, when available, the settled price. Optional filters: `condition_ids`, `event_slugs`, `outcomes` (only fire when the winning outcome matches, e.g. `"Yes"`), `exclude_shortterm_markets`. # Market volume milestone callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-volume-milestone-callback https://api.struct.to/webhookopenapi.json webhook market-volume-milestone Fired when a market's trading volume crosses a milestone threshold in the specified timeframe. **`timeframes` is required** (e.g. `["1h", "24h"]`) — valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. Optional `milestone_amounts` restricts to specific USD thresholds (e.g. `[10000, 100000]`). Optional `condition_ids` restricts to specific markets. # Market volume spike callback Source: https://docs.struct.to/api-reference/webhook-callbacks/market-volume-spike-callback https://api.struct.to/webhookopenapi.json webhook market-volume-spike Fired when a market's trading volume grows by a multiple of `spike_ratio` within a timeframe. Requires `spike_ratio` (> 1.0, e.g. `2.0` fires when volume doubles). Optional `window_secs` sets the observation window in seconds (max 600). Optional `timeframes` — valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `1d`, `24h`, `7d`, `30d`, `lifetime`. Optional `condition_ids` restricts to specific markets. # New market entry callback Source: https://docs.struct.to/api-reference/webhook-callbacks/new-market-entry-callback https://api.struct.to/webhookopenapi.json webhook trader-new-market Fired when a trader places their first trade in a specific market/condition (fires once per trader+market pair) # New trade callback Source: https://docs.struct.to/api-reference/webhook-callbacks/new-trade-callback https://api.struct.to/webhookopenapi.json webhook trader-new-trade Fired on fill-style trades only (`OrderFilled`, `OrdersMatched`, `ComboExecution`). Use `wallet_addresses` to watch specific traders, `min_usd_value` to filter by size, and `min_price`/`max_price` to restrict to a probability range. # Oracle events callback Source: https://docs.struct.to/api-reference/webhook-callbacks/oracle-events-callback https://api.struct.to/webhookopenapi.json webhook oracle-events Fired on every on-chain oracle event. Use `oracle_event_types` to restrict to specific kinds — valid values: `AssertionMade`, `AssertionDisputed`, `AssertionSettled`, `RequestPrice`, `ProposePrice`, `DisputePrice`, `Settle`, `QuestionResolved`, `QuestionEmergencyResolved`, `QuestionReset`, `QuestionInitialized`, `QuestionPaused`, `QuestionUnpaused`, `QuestionFlagged`, `QuestionUnflagged`, `ConditionResolution`, `NegRiskOutcomeReported`. Optional `condition_ids` narrows by market. # Position holder metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-holder-metrics-callback https://api.struct.to/webhookopenapi.json webhook position-holder-metrics Fired when holder metrics change for a position. # Position liquidity threshold callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-liquidity-threshold-callback https://api.struct.to/webhookopenapi.json webhook position-liquidity Fired when a position's USD order-book liquidity crosses a threshold. **`position_ids` is required**, plus at least one of `min_liquidity_usd` (fires when liquidity crosses **up** through the value) or `max_liquidity_usd` (fires when it crosses **down**). Thresholds are edge-triggered and re-arm once liquidity crosses back, unless `one_shot` is set (fire once, then delete). # Position metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-metrics-callback https://api.struct.to/webhookopenapi.json webhook position-metrics Fired when a position's volume or transaction metrics cross a configured threshold. Use `timeframes` to restrict to specific windows (valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`). # Position resolved callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-resolved-callback https://api.struct.to/webhookopenapi.json webhook trader-position-resolved Fired once when a trader's position is resolved (won or lost) # Position volume milestone callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-volume-milestone-callback https://api.struct.to/webhookopenapi.json webhook position-volume-milestone Fired when a position's trading volume crosses a milestone threshold in the specified timeframe. **`timeframes` is required** (e.g. `["1h", "24h"]`) — valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. Optional `milestone_amounts` restricts to specific USD thresholds. Optional `position_ids` or `condition_ids` restrict to specific positions/markets. # Position volume spike callback Source: https://docs.struct.to/api-reference/webhook-callbacks/position-volume-spike-callback https://api.struct.to/webhookopenapi.json webhook position-volume-spike Fired when a position's trading volume grows by a multiple of `spike_ratio` within a timeframe. Requires `spike_ratio` (> 1.0, e.g. `2.0` fires when volume doubles). Optional `window_secs` sets the observation window in seconds (max 600). Optional `position_ids`, `condition_ids`, `outcomes`, and `timeframes` to narrow scope. # Price spike callback Source: https://docs.struct.to/api-reference/webhook-callbacks/price-spike-callback https://api.struct.to/webhookopenapi.json webhook price-spike Fired when a position's price moves significantly. Use `min_price_change_pct` to set the minimum move (e.g. `10` for 10%). Use `window_secs` to observe moves within a specific time window. Use `spike_direction` (`"up"` | `"down"` | `"both"`) — defaults to `"up"` when not provided. Filter by `position_ids` or `outcomes` to narrow scope. Scope by market taxonomy with `tags` (matches a market's tags or category) and `series_slugs` (matches its parent series). Optional `min_price`/`max_price` gate the price band, `min_txns`/`min_volume_usd` require a minimum number of trades or USD volume in the observation window. # Price threshold crossing callback Source: https://docs.struct.to/api-reference/webhook-callbacks/price-threshold-crossing-callback https://api.struct.to/webhookopenapi.json webhook price-threshold Fired when an outcome's price crosses a target level (edge-triggered, fires once per crossing). **At least one of `min_price` or `max_price` is required.** Use `min_price` (e.g. `0.75`) to fire when the price crosses *up* to ≥ that level; use `max_price` (e.g. `0.25`) to fire when it crosses *down* to ≤ that level. Set `one_shot` to delete the subscription after the first delivery (requires `position_ids` or `condition_ids`). Set `fire_if_already_past` to fire immediately when the first observed price is already past the target. Optional scope filters: `condition_ids`, `position_ids`, `outcomes`, `position_outcome_indices` (`0` = Yes/Up, `1` = No), `event_slugs`, `tags` (a market's tags or category), `series_slugs` (parent series). # Tag metrics callback Source: https://docs.struct.to/api-reference/webhook-callbacks/tag-metrics-callback https://api.struct.to/webhookopenapi.json webhook tag-metrics Fired when a tag's volume or transaction metrics cross a configured threshold. Use `timeframes` to restrict to specific windows (valid values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`). # Typed trade event callback Source: https://docs.struct.to/api-reference/webhook-callbacks/typed-trade-event-callback https://api.struct.to/webhookopenapi.json webhook trader-trade-event Fired on every confirmed prediction trade. Payload is a tagged union of trade-type variants. # Whale trade callback Source: https://docs.struct.to/api-reference/webhook-callbacks/whale-trade-callback https://api.struct.to/webhookopenapi.json webhook trader-whale-trade Fired when a trade meets the configured criteria. Use `min_usd_value` to filter by minimum trade size (optional, defaults to 0 — matches all trades), and `min_price`/`max_price` to restrict to a traded price range. # Create webhook Source: https://docs.struct.to/api-reference/webhooks/create-webhook https://api.struct.to/webhookopenapi.json post /webhooks Create a new webhook subscription for real-time event notifications # Delete webhook Source: https://docs.struct.to/api-reference/webhooks/delete-webhook https://api.struct.to/webhookopenapi.json delete /webhooks/{webhook_id} Permanently delete a webhook subscription # Get webhook Source: https://docs.struct.to/api-reference/webhooks/get-webhook https://api.struct.to/webhookopenapi.json get /webhooks/{webhook_id} Retrieve details of a specific webhook by its ID # Get webhook delivery logs Source: https://docs.struct.to/api-reference/webhooks/get-webhook-delivery-logs https://api.struct.to/webhookopenapi.json get /webhooks/{webhook_id}/logs Returns delivery logs for a webhook (newest first), including the send time and the payload we delivered (large payloads and error messages may be truncated). Retained for 7 days. Keyset-paginated via `pagination_key`. # List event types Source: https://docs.struct.to/api-reference/webhooks/list-event-types https://api.struct.to/webhookopenapi.json get /webhooks/events Retrieve all available webhook event types with descriptions, categories, credits cost, and applicable filter fields # List webhooks Source: https://docs.struct.to/api-reference/webhooks/list-webhooks https://api.struct.to/webhookopenapi.json get /webhooks Retrieve a paginated list of your webhook subscriptions with optional filtering # Rotate webhook secret Source: https://docs.struct.to/api-reference/webhooks/rotate-webhook-secret https://api.struct.to/webhookopenapi.json post /webhooks/{webhook_id}/rotate-secret Generate a new HMAC secret for a webhook. The new secret is returned once — store it securely # Test webhook Source: https://docs.struct.to/api-reference/webhooks/test-webhook https://api.struct.to/webhookopenapi.json post /webhooks/{webhook_id}/test Send a test payload to the webhook URL to verify connectivity # Update webhook Source: https://docs.struct.to/api-reference/webhooks/update-webhook https://api.struct.to/webhookopenapi.json put /webhooks/{webhook_id} Update an existing webhook's URL, filters, or status # Backend Source: https://docs.struct.to/changelog/backend Updates on Struct APIs, webhooks, websockets or other services ## v0.6.5: Combo PnL & Analytics Trader PnL now runs on an improved mark-to-market engine behind the same base routes, with no path or response-shape break. Two headline unlocks: **combo (parlay) PnL** for a wallet, and **combo analytics** for market-level parlay activity across Polymarket. **No migration required.** Existing `/trader/pnl/...` routes keep the same paths and shapes. New combo endpoints and fields are additive — existing integrations keep working and pick up the improved calculation automatically. * **Trader combo PnL**: `/pnl/{address}/combos` lists a wallet's parlays with filters, sort, and pagination; `/pnl/{address}/combo` expands one combo by `condition_id` or `position_id` into legs, implied probability, and potential payout. Walkthrough in [Tracking a trader's combo (parlay) PnL](/guides/trader-combo-pnl). * **Combo analytics**: market-level [Combos](/api-reference/combos/list-combo-markets) APIs — [list parlays](/api-reference/combos/list-combo-markets), [metrics](/api-reference/combos/get-combo-metrics), [legs](/api-reference/combos/expand-a-combo-into-its-legs-with-market-metadata), holders, and candlesticks, plus global analytics ([snapshot](/api-reference/combos/global-combo-analytics-snapshot), [% change](/api-reference/combos/global-combo-analytics-pct-change), [deltas](/api-reference/combos/global-combo-analytics-candles-per-bucket-deltas), [timeseries](/api-reference/combos/global-combo-analytics-timeseries-cumulative)) for volume, fees, lifecycle, and trader activity. * **Combo fields on existing surfaces**: position and market rows carry `combo_type: "combinatorial"` when the market is a combo (parlay); filter with `combo=combinatorial`. Trader summaries and the global leaderboard add `combo_trade_count` so you can rank wallets by parlay activity. ## v0.6.4: WebSocket Compression & Firehose Mode Two connection-level additions to the [Rooms websocket](/websockets/getting-started): opt-in zstd compression for high-throughput sockets, and a `subscribe_all` firehose flag that turns most rooms into a full, unfiltered stream. No changes to existing subscriptions, filters, or payloads. * **zstd compression.** Negotiate once at the WebSocket upgrade with the `X-Ws-Compression: zstd` header, or the `?compression=zstd` query fallback for browser clients that can't set headers. When enabled, room envelopes arrive as binary frames of zstd-compressed JSON; decompress and `JSON.parse` as usual. Omit both for the default uncompressed text frames. See [Compression](/websockets/compression). * **Firehose (`subscribe_all`).** Most rooms now accept `subscribe_all: true` on the subscribe message to receive every update on the stream instead of a targeted subset. Targeting filters (`condition_ids`, `traders`, `wallets`, and so on) are ignored while it's set, while secondary filters like `timeframes`, `dirty_kinds`, and `reasons` still apply. `polymarket_order_book` delivers its firehose as conflated `order_book_batch` envelopes every 50ms. Billing is still per message, so firehose subscriptions can be high volume. See [Firehose](/websockets/firehose). * **Playground support.** The [dashboard websockets playground](https://www.struct.to/dashboard/websockets) exposes both: a compression toggle on the connection card and the `subscribe_all` switch per room. ## v0.6.3: Faster Trades WebSocket A plumbing rework of the confirmed [`polymarket_trades`](/websockets/rooms/trades) path shortens the hop from a confirmed block to a socket push, so fills land on your socket sooner with no change to the subscription, filters, or payload. * **Confirmed feed now leads Polymarket's RTDS.** On the default `confirmed` status, trades surface a median of \~0.5s ahead of Polymarket's public RTDS activity feed, leading on roughly 85 to 90 percent of matched fills, even though both paths wait for the same on-chain block. * **Mempool feed still further ahead.** `status: "pending"` continues to read the Polygon mempool and lead RTDS by a median of 2.6s on 100 percent of matched trades. Use `status: "all"` to receive both the pending and confirmed sighting of each trade from a single subscription. * **No integration changes.** Same room, same filters, same fields. Existing subscribers get the speedup for free. * **Benchmark**: full method and per-window numbers across \~15,000 live trades in [Polymarket RTDS vs Struct Trades WS](https://www.struct.to/blog/polymarket-rtds-vs-struct-trades-ws). The harness is open source and reproducible with a free API key. ## v0.6.2: Liquidity Streams & Price-based Filters Three new [Websocket rooms](/websockets/rooms/liquidity) stream real-time USD order-book liquidity: `polymarket_position_liquidity` (per outcome token), `polymarket_market_liquidity` (per market), and `polymarket_event_liquidity` (per event). Filter by id or omit for a firehose. Billed at 0.001 credits per message. Trade-driven webhook and alert filters now gate on price instead of probability, and the standalone `probability_spike` event has been retired. * **`min_price` / `max_price` replace `min_probability` / `max_probability`.** The bound filters on `close_to_bond`, `price_spike`, `price_threshold`, and the trader events (`trader_first_trade`, `trader_new_market`, `trader_new_trade`, `trader_trade_event`, `trader_whale_trade`) now take `min_price` and `max_price` on a `0.0`–`1.0` scale. Subscriptions using the old field names must be updated. * **`probability_spike` removed.** Use [`price_spike`](/webhooks/price-spike) instead. It fires on the same moves and carries the same payload fields. * **`min_probability_change_pct` is unchanged.** The `position_metrics` percentage-change filter keeps its name. ## v0.6.1: Threshold Crossings, Resolution & Dispute Events, Taxonomy Filters Three new webhook and alert events, plus market-taxonomy scoping across the trade-driven alerts. * **`price_threshold`**: an edge-triggered alert that fires once when an outcome's price crosses a target level. Set `min_probability` for an upward target or `max_probability` for a downward one. `one_shot` deletes the subscription after its first delivery, and `fire_if_already_past` fires immediately when the first observed price is already past the target. Available as a [webhook](/webhooks/price-threshold) and an [alert](/websockets/alerts/price-threshold). * **`market_resolved`**: fires once when a market reaches a terminal resolution, collapsing the underlying oracle events (`QuestionResolved`, `QuestionEmergencyResolved`, `ConditionResolution`, `NegRiskOutcomeReported`) into a single deduplicated delivery carrying the winning outcome and, when available, the settled price. Available as a [webhook](/webhooks/market-resolved) and an [alert](/websockets/alerts/market-resolved). * **`market_disputed`**: fires when a proposed resolution is challenged on-chain (`DisputePrice` or `AssertionDisputed`), carrying the disputer, the proposer, and the disputed outcome. Available as a [webhook](/webhooks/market-disputed) and an [alert](/websockets/alerts/market-disputed). * **Taxonomy filters on trade-driven events**: `probability_spike`, `price_spike`, and `close_to_bond` now accept `tags` (matches a market's own tags or its category) and `series_slugs` (matches the market's parent series). Both are case-insensitive, so you can scope deliveries to a topic without listing individual condition IDs. * **Fire-and-delete (`one_shot`)**: every webhook event now accepts `one_shot`, which deletes the subscription after its first successful delivery. It fires exactly once, test deliveries never consume it, and a failed delivery leaves the subscription in place. See [Fire-and-Delete](/webhooks/fire-and-delete). ## v0.6: PnL v3, Mark-to-Market A complete rebuild of how Struct calculates, stores, and serves PnL. v3 switches from cost-basis to mark-to-market accounting at the block level, so a position's value reflects on-chain reality the moment you buy, with no sell or resolution required. Twenty new REST endpoints across three namespaces, new Websocket rooms, and an updated TypeScript SDK. **No migration required.** v3 is a drop-in update. Every existing v2 PnL endpoint is automatically upgraded: same routes, same response shape, no fields removed. v3 only adds new fields on top, so existing integrations keep working and get the improved calculation for free. * **Mark-to-market accounting**: PnL is now current market value of your shares minus what you paid, recomputed on every block. Realized and unrealized are tracked separately, realized settling on every sell, merge, or redemption, and unrealized updating per block. Merges, splits, neg-risk conversions, redemptions, fees, rebates, rewards, and yield are all folded into the number, so the figure is the actual economic outcome of holding a position, not just price-times-shares minus cost. * **Trader PnL namespace**: `/trader/pnl/{address}` returns the headline summary (realized PnL, open-position value, volume, win rate, profit factor, avg win/loss, best/worst trade, avg hold time, fees, and a full breakdown of merges/splits/conversions/redemptions/rebates/rewards/yield). Companion endpoints expose `/pnl/{address}/candles` (OHLC for total PnL and portfolio value, block-tagged), `/pnl/{address}/exits` (closed-position markers with `reason` of `resolved_win` / `resolved_loss` / `sold_win` / `sold_loss`), `/pnl/{address}/periods` (best/worst day, week, month), `/pnl/{address}/changes` (per-window deltas in one call), and `/pnl/{address}/risk` (max drawdown, current drawdown, max runup). * **Trader breakdowns** (`/positions`, `/markets`, `/events`, `/categories`): every tab of a trader profile from one namespace. `/positions` takes a required `status=open|closed` filter and returns current price, shares balance, average entry/exit, realized PnL, percent return, and `redeemable` / `mergeable` flags; `/markets`, `/events`, and `/categories` roll the same activity up a level. * **Leaderboards**: five PnL-ranked endpoints at different scopes, all sharing fields, sort options, and pagination: `/trader/global_pnl` (every tracked trader), `/events/top-traders`, `/market/top-traders`, `/market/position/top-traders`, and `/tags/top-traders`. * **Biggest wins** (`/trader/top_trades_markets`): ranks every market by aggregate realized PnL across all traders over 1d, 7d, 30d, or lifetime. * **Top holders**: live top-holder lists for any market or position. Pass `include_pnl=true` for each holder's full PnL breakdown, plus holder-count time series at event, market, and position level. * **Real-time PnL over Websocket**: subscribe to `polymarket_trader_pnl` (global, market, event, and category granularities, pushed typically within 50ms of block confirmation) with price-driven, trade-driven-only, or window-rollover trigger modes. Two position rooms cover the lifecycle: `polymarket_trader_positions` (`trader_position_lifetime_update` on every PnL change) and `polymarket_trader_position_resolved` (fires once on resolution with `won` and final realized PnL). * **Explorer and SDK**: every surface on [explorer.struct.to/traders](https://explorer.struct.to/traders) (trader profiles, leaderboards, holder views, PnL charts) now runs on v3, and the [TypeScript SDK](https://www.npmjs.com/package/@structbuild/sdk) is fully updated. ## v0.5.3: OHLC Candles & Webhook Logs * **OHLC candles for Up/Down assets**: new `/v1/polymarket/asset-history/candlestick` endpoint returns OHLC price bars per asset at TradingView resolutions (`1S`, `1`, `5`, `15`, `30`, `60`, `240`, `D`, `1D`). * **Webhook delivery logs**: inspect delivered webhook payloads in the dashboard or via the [delivery logs API](/api-reference/webhooks/get-webhook-delivery-logs). * **Documentation**: expanded pagination, authentication, and Crypto Up/Down guides. ## v0.5.2: Tag Metrics & Builder Attribution Builder analytics extended down into the real-time metric streams, plus a new tag-level rollup and a cleaner trade-event taxonomy for protocol payouts. **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. * **Tag metrics streams**: new `polymarket_tag_metrics` Websocket room, new `tag_metrics` alert event, and new `tag_metrics` webhook. Subscribe by tag label or slug to receive aggregated volume, fees, transactions, and unique-trader counts across the standard timeframes (`1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`). * **Builder-attributed metric fields**: every metric payload (market, event, position, condition, tag) now ships builder-attributed counterparts: `builder_usd_volume`, `builder_shares_volume`, `builder_fees`, `builder_txns`, and `unique_builder_traders`. Position metrics additionally split builder activity by side (`builder_usd_buy_volume` / `builder_usd_sell_volume`, `builder_shares_buy_volume` / `builder_shares_sell_volume`, `builder_buys` / `builder_sells`). * **Shares volume across metrics**: every metric event now includes a top-level `shares_volume` (decimal string on rooms, number on alerts and webhooks) alongside `usd_volume`, with matching breakdowns on position metrics (`shares_buy_volume`, `shares_sell_volume`). * **Position-metric trade sizing**: position-metric payloads now include `avg_trade_shares`, `avg_buy_shares`, and `avg_sell_shares` so you can track average ticket size per window without computing it client-side. * **Trades stream payout credits**: `MakerRebate`, `Reward`, and `Yield` join the trades discriminator for pUSD credits paid directly to trader wallets, replacing the previous `Approval` variant. The three variants share one payload shape (`taker` is the payout distributor, `shares_amount` / `price` / fees are always `0`). * **`image_url` on trade variants**: `OrderFilled`, `OrdersMatched`, `Redemption`, `Merge`, `Split`, `Cancelled`, `RegisterToken`, and oracle lifecycle events on `polymarket_trades` now carry `image_url` so you can render trade cards without a follow-up market lookup. ## v0.5.1: Builder Analytics * **Builders namespace** (`/v1/polymarket/builders`): query any Polymarket builder by code with latest metrics, deltas / changes / timeseries analytics across the same bucket resolutions as the analytics API, current fee rate and full fee history, cohort retention, tag breakdowns, top traders, and concentration. * **Global builder rollups**: aggregate stats, bucketed composition, and tag distribution across all builders via `/builders/global`, `/builders/global/tags`, and `/builders/tags/{tag}` for ranking builders within a category. * **Builder metadata**: list and look up builder display info (name, image, links) via `/builders/metadata` and `/builders/{builder_code}/metadata`. * **Webhook payload enrichments**: spike webhooks (volume, probability, price) now carry `question`, `market_slug`, `event_slug`, `image_url` (and `event_title` on event-scoped events) so you can render alerts without a follow-up lookup. ## v0.5: CLOB v2, Analytics, and Oracle Events Polymarket's CLOB v2 cutover lands April 28. v0.5 ships three days early with full v2 support, plus a brand-new analytics API and an oracle events stream. **No breaking changes.** Every integration built on Struct continues to work without modification. CLOB v2 is wired in transparently behind the existing surfaces. Order, trade, and account fields are additive, not replaced. You do not need to update your code to keep running. * **CLOB v2 ready**: order, trade, and account surfaces now reflect the v2 schema. New `builder_fee` and `builder_code` on trades, `CTFExchangeV2` and `NegRiskExchangeV2` on the `exchange` discriminator, and a new `pusd_update` event on the accounts websocket alongside the existing `usdce_update` and `matic_update`. * **Analytics namespace** (`/v1/polymarket/analytics`): a dedicated analytics API with three query modes (deltas, changes, timeseries) across five scopes (global, event, market, tag, trader). Bucket resolutions span 60m, 4h, 1d, 1w, 1mo. Includes merge/split volume tracking, a buy-distribution histogram (under $10 through $50k+), and distinct-address counters at every scope. * **Oracle events stream**: a new websocket room (`polymarket_oracle_events`) and webhook event covering 16 UMA assertion lifecycle events including `AssertionMade`, `AssertionDisputed`, `AssertionSettled`, `ProposePrice`, `DisputePrice`, `QuestionResolved`, `QuestionEmergencyResolved`, and `NegRiskOutcomeReported`. Pending events appear from the mempool before chain confirmation, with confirmed events following once the block lands. * **Top traders endpoints**: rank traders by realized PnL on events, markets, and positions over 1d, 7d, 30d, or lifetime windows. * **Tag and bond sorting**: sort tags by volume, txns, unique traders/makers/takers, or fees over 1h through 1y windows, and bond markets by APY, liquidity, volume, or end date. * **Expanded room and alert filters**: websocket subscriptions and webhook filters got broad expansion, especially around oracle metadata and trader scoping. * **Docs**: see the [API Reference](/api-reference/getting-started) for the new analytics endpoints, and the [Polymarket CLOB v2 migration guide](https://docs.polymarket.com/v2-migration) for context on the v2 cutover. ## v0.4.1 * **New trade event webhook**: receive structured events for every trade type (fills, splits, merges, redemptions, conversions) without parsing raw payloads. Ships as the new `TradeEvent` webhook under the Trader category. * **Struct CDN**: market and event images are now served from the Struct CDN, so `image_url`. * **CLOB rewards streaming**: track reward-earning markets in real time through a new rewards Websocket room, plus `sort_by=rewards` and reward filters on the market API. ## v0.4: WebSockets Websockets are now generally available. Stream real-time Polymarket data directly to your application over two endpoints, authenticated with the same `X-API-Key` you use for REST. * **Rooms (`wss://api.struct.to/ws`)**: subscribe to topic-based data streams for trades, asset prices and candle windows, market / event / position metrics, trader PnL and positions, accounts, order books, and CLOB rewards. Each room exposes its own filters (condition IDs, market slugs, wallet addresses, asset symbols, timeframes, and more) so you only receive the messages you care about. * **Alerts (`wss://api.struct.to/ws/alerts`)**: receive the full catalog of webhook events (whale trades, PnL crossings, probability and volume spikes, milestones, and more) pushed over a persistent Websocket connection, with no public HTTP endpoint to host. * **Per-message credit billing**: pay only for messages actually delivered to your connection, with a 1 credit hold on connect and per-room rates starting at 0.01 credits per message. * **Docs**: see [WebSockets → Getting Started](/websockets/getting-started) for your first connection, the [Rooms catalog](/websockets/rooms) for every available stream and its filters, [Alerts](/websockets/alerts) for the webhook-over-Websocket endpoint, and [Pricing](/websockets/pricing) for per-room rates. * Fixed Websocket trade serialization to send numeric fields as strings and return null for 'side' field on non-fill trade types (Redemption, Merge, Split) * Added wallet tracking webhook and new Websocket event types for market trades, asset prices, and candle data updates * Enhanced position and market metrics with granular timeframe-based analytics * Fixed position title display and search to use market title as fallback when position title is missing, and improved position search to match against both position and market titles * Fixed position ID conversion from hexadecimal to decimal format for consistent position identifiers * Removed 'worst\_trade' sort option from global PnL leaderboard API and fixed worst trade calculation to only consider closed market positions * Added min\_shares filter to trader position PnL endpoint to exclude dust positions * Added current\_shares\_balance and image\_url fields to trader positions and trades with real-time Websocket updates * Expanded REST API with comprehensive market analysis endpoints including trader PnL tracking, order book data, trades, price movements, metrics, volume charts, spread history, and position data * Added offset-based pagination to trader trades and P\&L endpoints, with offset taking precedence over cursor-based pagination * Search endpoint now supports type filtering and optional trader PnL data enrichment with dynamic credit pricing * API endpoints now return empty results instead of 404 errors when data is not found; PnL endpoints default to lifetime calculations; renamed pnl\_usd to realized\_pnl\_usd for clarity across REST and Websocket APIs * Updated PnL data types and tracking system to V2 versions across all PnL-related APIs and data streams, including timestamp field on trader PnL candle data for improved data freshness tracking * Added comprehensive PnL tracking with position-level and market-level metrics, enriched candle and calendar endpoints with multiple timeframe options (1d, 7d, 30d, lifetime), and pagination support * Enhanced webhook system with wallet address allowlist filtering, timeframe filtering for PnL events, condition ID and sell volume filtering, new trade execution notifications, and spike webhooks now include previous and current probability/price values with 10-second deduplication across subscriptions * Positions API now includes market slug and image URL for each position with market\_slug as a filterable query parameter, and renamed buy/sell USD fields to total\_buy\_usd and total\_sell\_usd for clarity * Renamed query parameters ('start'/'end' to 'from'/'to') and standardized response field names across order book, PnL candles, and Websocket chart data endpoints * Added support for DOGE, BNB, and HYPE asset symbols in price history queries, webhooks, Websocket filters, and asset price API with validation for unsupported symbols * Added realized\_pnl\_usd sorting option for positions with automatic win rate and average PnL metrics calculation * Added comprehensive Order Book APIs with real-time Websocket streams, historical snapshots, and spread time-series data with filtering by market, condition, and position ID * Added support for 4-hour (4h) interval in asset price history across all APIs and webhooks * Improved Polymarket market search with relevance scoring and enhanced validation for character counting and timeframe parameters * Improved orderbook parsing to support both object and array formats for bid/ask levels * Fixed orderbook metrics calculation, market endpoints to return proper 404 responses, and eliminated duplicate history records from redundant Websocket connections * Added 24-hour volume data to AI market responses and introduced an AI credit system with automatic billing for API, Websocket, and webhook usage * Added `exclude_shortterm_markets` boolean flag to webhook filtering options * Implemented connection and message rate limiting (30 connections per 60 seconds, 30 messages per second per organization) with improved error handling and validation to ensure clients join rooms before messaging * Reduced Websocket ping timeout from 30 minutes to 30 seconds for faster detection of inactive connections * Fixed credit tracking and webhook suspension logic to prevent negative balances and duplicate suspensions * Enhanced price-spike and volume-spike webhooks with configurable thresholds, time windows, and deduplication to detect market movements with reduced latency and false positives * Webhook management enforces plan-based limits with automatic deletion or suspension on subscription changes * Removed min\_usd\_value filter from asset price tick webhooks; use asset\_symbols to filter tracked crypto assets instead * Purchased credits now persist when changing subscription plans, with add-on credits consumed after plan credits are exhausted * Added comprehensive event data including 24-hour volume, credit usage tracking, AI-optimized API response formats, and new webhook event types (Close to Bond, Market Created, Asset Price Ticks, Window Updates, Condition/Event/Position Metrics, Volume/Probability Milestones, and Volume Spikes) * Introduced subscription filters for webhook events, enabling filtering by market, event, trader, position, and other criteria * Improved Websocket price feeds with immediate updates on new candle periods and enhanced webhook performance through rate limiting and stricter validation * Updated market webhook payloads to use server timestamps, enriched events with Gamma API category metadata, and now include all markets regardless of metadata availability * Fixed whale trade webhooks to only trigger for actual OrderFilled trades and corrected partial updates validation * Webhooks now support per-endpoint event queue limits with enhanced delivery analytics tracking all attempts, failures, and error details * Search API now returns markets and traders alongside events with independent pagination, new sorting parameters, and increased base credits from 2 to 3 * Added support for NegRisk contract adapter with new trade types (Split, Merge, Redemption, PositionsConverted) and enriched metadata * Market data processing now automatically recovers from service crashes and replays pending updates on startup * Trades stream payload includes updated confirmation timestamps with improved accuracy and deduplication logic * Fixed trader PnL data accuracy, API cache separation between public and authenticated requests, and catchup buffer deduplication * Added comprehensive webhook events for trader transactions, market entries, volume spikes, probability spikes, and real-time asset price updates with customizable timeframes, thresholds, and asset symbol filtering, plus 24-hour credit usage tracking with analytics * Enhanced data reliability across trader PnL candles, condition metrics, event metrics, holder stats, and liquidity streams with exactly-once delivery guarantees and improved consumer catchup with non-blocking ack signal draining * Improved Polymarket event and market search performance with optimized title-based search and faster indexing * Removed price and probability open/close/high/low fields from Polymarket event and condition metrics * Added `ai=true` query parameter to API endpoints for optimized AI agent responses with truncated JSON payloads * Webhook events API now includes category, credits cost, and applicable filter fields for each event type, with validation for webhook filters and subscriptions to enforce required fields at creation time * Webhook delivery logging now visible by default with info-level logs for successful deliveries and warning-level logs for failures, including delivery ID, status code, and duration * Fixed webhook credit deduction to use per-event costs instead of a global rate, eliminating silent webhook drops * Added price jumps detection API endpoint (/v1/polymarket/market/price-jumps) that identifies significant price movements (configurable threshold, default 10%) within configurable candle windows (1, 5, 15, 30, 60, 240 minutes), with support for condition\_id or market\_slug lookup and millisecond timestamps for direct compatibility with the trades API * Trader PnL now displays accurate average hold time for lifetime statistics * Webhooks API endpoints updated to `/v1/webhooks` and now support both API key and bearer token authentication methods * Websocket price updates now only trigger when candles are fully settled, preventing mid-candle close price changes and duplicate data during reconnections * Added new webhook event types (market\_created, volume\_spike, volume\_milestone, probability\_spike, position\_metrics, first\_trade, new\_market, whale\_trade, close\_to\_bond, and PnL tracking) with customizable filters, 6-hour deduplication, concurrent execution support, and 7-day/30-day metrics periods * Webhook subscriptions now use a single `event` field per subscription; migrated API endpoints from /frontend/webhooks/\* to /v1/webhook/\* with volume testing endpoints to estimate delivery costs * Added webhook management endpoints (list\_events, rotate\_secret) and improved Websocket PING/PONG handling for more reliable connection management * Asset history now returns null for incomplete time windows, webhook outcomes properly zero out incomplete candle data, and token account data on Polygon filters to Polymarket-relevant collateral tokens (USDC and USDe) * Added trader PnL statistics including average PnL per market, average PnL per trade, and average hold time * Implemented webhook deduplication to prevent duplicate webhooks with the same URL and event configuration * Webhook API restructured with organization-based authentication, new event listing endpoint, and secret rotation capability * FirstTradePayload webhook schema updated with improved numeric precision (f64 with 6 decimal places), removed unnecessary fields, and renamed slug to market\_slug * Removed 'All' event variant from webhook subscriptions and failure tracking fields for more precise event filtering and cleaner responses * Market images now included in trade results and position queries across all APIs * Asset price history query time bounds extended by 1 day to include in-progress candles with immediate queryability upon candle opening * Real-time price updates now powered by Chainlink Websocket integration for improved accuracy and reliability * Price change percentage automatically computed from open and close prices for better data consistency * Fixed asset price history data alignment across Websocket, REST API, and backfill sources, with improved candle initialization and correct handling of price conflicts and invalid values * Added market and event chart endpoints with TradingView-style resolutions (1H, 6H, 1D, 1W, 1M, ALL) with 2-3x faster queries via pre-aggregated data * Extended markets and outcomes endpoints with new fields: series\_slug, accepting\_orders, uma\_resolution\_status, liquidity\_usd, and liquidity\_updated\_at * Added asset price history API endpoint for BTC, ETH, XRP, and SOL across multiple timeframes with cursor-based pagination using Chainlink price data * Enhanced trader profile batch endpoint with lifetime timeframe support and ability to retrieve winning outcomes for resolved markets * Improved Websocket and Kafka resilience with automatic reconnection, sharded connections with redundancy, and exponential backoff retries * Fixed trader PnL candles API response and improved chart query performance by \~75% for 1-week resolution * API now uses explicit field names (market\_slug, event\_slug, created\_time, start\_time, end\_time) with full backwards compatibility * Added real-time Polymarket CLOB Websocket streaming with market price feeds, sports updates, user order/trade channels, and automatic reconnection * Added position-level PnL tracking and querying across 1d, 7d, 30d, and lifetime timeframes with filtering and sorting options * Market responses enhanced with resolution source, CLOB rewards, individual outcome prices, lifetime metrics, detailed PnL analytics, and full outcome details including outcome\_index and MarketStatus 'all' filter support * Improved data quality by filtering invalid trades, protocol contract activities, fixing market outcome prices, correcting trade fees, and determining market closed status from Gamma metadata * Added shares\_usd field to event holder endpoints and expanded metrics API with new trading volume, fees, and trader count endpoints across multiple timeframes * Improved Trader Positions endpoint with sorting by shares, USD value, PnL, and price, plus event slug filtering and increased limits to 50/200 * Enhanced market tracking with accepting\_orders and uma\_resolution\_status fields, Cancelled trade type, and accurate UMA assertion state tracking; improved dispute tracking for UMA oracle markets with clear status distinction between disputed and settled outcomes * Updated API responses to use typed enums instead of integers, numeric volume metrics, and Unix epoch format for timestamps; standardized end\_time field naming and increased recommended markets endpoint limit to 250 * Added cursor-based pagination support to candlestick, volume chart, and trader endpoints for better time-series data handling * Fixed market status API mapping and Websocket URL configuration for Polygon chain; fixed UMA resolution status and market status updates to correctly handle out-of-order events and prevent data loss * Market metrics API now returns nested metrics organized by multiple timeframes (1m, 5m, 30m, 1h, 6h, 24h, 7d, 30d) with volume, fees, transaction count, unique traders, and open interest change * Added real-time PnL tracking across multiple timeframes for traders at market, event, and global levels with best/worst trade statistics and enhanced leaderboard sorting * Restructured trader positions API with clearer field names and nested market-level PnL data under pnl field, plus market metadata in responses * Simplified PnL data in holder responses with a single realized\_pnl\_usd field across event, market, and position holders * Fixed share balance scaling issue in market holders, position holders, and trader positions endpoints * Improved performance of holder and position queries through SQL refactoring with subquery patterns * Added 7-day and 30-day metrics timeframes to events and markets with optional include\_metrics parameter; market metrics now include volume, fees, transaction counts, trader counts, and open interest across all timeframes (1m, 5m, 30m, 1h, 6h, 24h, 7d, 30d) * Added ability to fetch up to 50 events by ID or slug using comma-separated lists with enhanced sorting options (title, relevance, creation\_date, start\_date, EndTime, StartTime, CreatedTime) * Implemented cursor-based pagination for event and market listing with up to 100 items per page, using base64-encoded pagination\_key parameter * Enhanced market filtering with advanced options including volume, liquidity, holders, tags, and status, plus bulk search across multiple fields * Market API now returns granular time fields (created\_time, start\_time, game\_start\_time, closed\_time, end\_time) instead of end\_date and resolution\_date across REST and Websocket APIs, and market status is now returned as a string ("open"/"closed") instead of a number * Events with a future start\_date now return status "upcoming" instead of "open", with added support for filtering by status and improved event status logic to distinguish between upcoming, open, and closed events; added show\_market\_images field to event list responses # Account Source: https://docs.struct.to/dashboard/account Manage your Struct account settings and profile. Your account is your personal identity on Struct. It holds your login credentials, profile details, and personal preferences. ## Creating an Account Head to [struct.to/dashboard](https://struct.to/dashboard) and sign up with your email or a supported OAuth provider. Check your inbox for a verification email and click the confirmation link. After verifying, you'll be prompted to create your first [organisation](/dashboard/organisations). All API keys and billing are scoped to organisations. ## Profile Settings From the account settings page you can update: * **Display name**: your name as shown across the dashboard * **Email address**: used for login and notifications * **Password**: update your password at any time ## Deleting Your Account Account deletion is permanent and cannot be undone. All organisations you own and their associated API keys will be revoked immediately. To delete your account, navigate to **Account Settings** and select **Delete Account**. You will be asked to confirm before the action is processed. # AI Chat Source: https://docs.struct.to/dashboard/ai-chat Chat with StructBot to explore prediction markets, analyze trades, and detect insider activity. AI Chat gives you a conversational interface to the Struct API. Ask questions in plain language and StructBot will query markets, profile traders, and surface insights, all without writing code. ## Agents StructBot has specialized agents optimized for different tasks. Select an agent when starting a new chat, or browse them from the **Agents** page. ### General The default agent for open-ended exploration. It can search markets, look up traders, pull metrics, and answer questions about Polymarket activity. * Access to all Struct API tools * Best for: trending markets, trader lookups, market analysis, general Q\&A ### Insider Finder A specialized agent that detects potential insider trading patterns on Polymarket. It follows a structured methodology: find price moves first, then identify who traded before the move. * Delegates trader profiling to subagents for parallel investigation * Best for: pre-move trade detection, wallet investigation, suspicious activity analysis ## Models Toggle between two model modes using the badge in the chat header: * **Fast**: lower latency, good for simple lookups * **Capable**: higher quality reasoning, better for complex analysis Your model preference is saved per browser session. ## Tools StructBot has access to the full Struct API as tools. When you ask a question, the agent searches for the right API method, executes it, and presents the results, often in rich UI components like market cards, trader profiles, and data tables. If your question is ambiguous, the agent will ask you to clarify by presenting multiple-choice options before proceeding. You need an active API key to use AI Chat. The agent authenticates with your selected key to access the Struct API. ## File Attachments You can attach files to your messages for the agent to reference. * **Images**: JPEG, PNG, GIF, WebP * **Documents**: PDF * Up to 5 files per message ## Chat Management ### History All conversations are automatically saved. Access previous chats from the sidebar, organized by most recent activity. ### Folders Organize chats into folders from the sidebar. Create, rename, and delete folders, or drag chats between them. ### Pinning Pin important chats to keep them at the top of your sidebar for quick access. ### Sharing Share a chat with others via a unique link. Choose between: * **Private**: only accessible to people with the link * **Public**: discoverable and accessible to anyone Shared chats are read-only for viewers. You can revoke access at any time. ### Regeneration Click the regenerate button on any assistant message to get a new response. The original message is preserved in history. ### Voting Use the thumbs up / thumbs down buttons on assistant messages to provide feedback on response quality. ## Pricing AI Chat usage is billed in credits from your organisation's plan. Each message consumes credits based on the tokens used. The chat header displays your current token usage for the conversation. See [Pricing & Ratelimits](/introduction/pricing) for details. # API Keys Source: https://docs.struct.to/dashboard/api-keys Generate and manage API keys for authenticating with Struct APIs. API keys are used to authenticate every request to the Struct API. Each key is scoped to an [organisation](/dashboard/organisations) and inherits its plan's rate limits and credit allowance. ## Creating an API Key Navigate to the **API Keys** section in your organisation's dashboard. Click **Create Key**, give it a descriptive name (e.g. `production`, `staging`), and confirm. Your full API key is shown once at creation. Copy it and store it somewhere secure. API keys are only displayed in full once. If you lose a key, you'll need to revoke it and create a new one. ## Using Your API Key Include your API key in the `X-API-Key` header on every request. ```bash cURL theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: YOUR_API_KEY" ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.struct.to/v1/polymarket/market", { headers: { "X-API-Key": "YOUR_API_KEY", }, }); ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, ) ``` ## Managing Keys From the API Keys page you can: * **Rename** a key to keep your list organised * **Revoke** a key to immediately disable it. Any requests using that key will return `401 Unauthorized` There is no limit on the number of API keys per organisation. Use separate keys for different environments or services so you can revoke them independently. ## Security Best Practices * Never expose API keys in client-side code or public repositories * Use environment variables or a secrets manager to store keys * Rotate keys periodically by creating a new key, migrating your services, then revoking the old one * Use separate keys for production and development # Automations Source: https://docs.struct.to/dashboard/automations Create event-driven workflows that react to Polymarket activity in real time. Automations let you build workflows that trigger when specific Polymarket events happen (like whale trades, price spikes, or new markets) and deliver results to Discord, Telegram, a webhook, or your Struct chat. Each automation connects a [webhook event](/webhooks/getting-started#available-events) to either an AI agent that analyzes the data or a template that formats it, then routes the output to a destination of your choice. ## Creating an Automation Navigate to **Automations** in your dashboard sidebar. Click **New Automation**. You can start from a pre-built template or build from scratch. Pick the webhook event that will fire your automation (e.g. `trader_whale_trade`, `price_spike`). See [Available Events](/webhooks/getting-started#available-events) for the full list. Choose between **AI** mode (an AI agent analyzes the event data) or **Direct** mode (a Handlebars template formats the data). See [Processing Modes](#processing-modes) below. Choose where results are delivered: Chat, Discord, Telegram, or a custom webhook. See [Output Destinations](#output-destinations) below. Set a rate limit, toggle the automation on, and save. A webhook subscription is automatically created for you. ## Processing Modes Every automation uses one of two processing modes to handle incoming event data. ### AI Mode The event data is sent to an AI agent along with your system prompt and user prompt template. The agent can analyze the data, use Struct API tools to look up additional context, and produce a natural-language response. * Uses an AI model with access to Struct API tools * Supports up to 20 tool-call steps per run * Best for analysis, recommendations, and investigations ``` System prompt: "You are a prediction market analyst. Assess new markets for trading opportunities." User prompt template: "A new market was just created. Analyze it: {{event}}" ``` ### Direct Mode The event data is interpolated into your Handlebars template with zero AI processing. The rendered template output is your result. * No AI calls, instant delivery * Uses [Handlebars template syntax](/dashboard/automations-templates) with custom formatters * Best for structured alerts, data forwarding, and notifications ``` Whale Trade Detected Market: {{event.market_name}} Side: {{uppercase event.side}} Size: {{formatCurrency event.size_usd}} ``` ## Output Destinations Each automation delivers its result to one of four destinations. ### Chat Results are saved as conversations in your Struct dashboard. AI-mode runs store both the prompt and response as a chat history you can review later. ### Discord Send results to a Discord channel via webhook. You can configure rich embeds with: * Custom title, color, and footer text * Thumbnail and image URLs * Structured fields (inline or full-width) * Optional message content outside the embed All text fields support Handlebars template syntax, so embed values can be dynamically populated from event data. Discord embeds have a 4,096-character limit for the description field. Longer outputs are automatically truncated. ### Telegram Send results to a Telegram chat via bot. Configuration options include: * Bot token and chat ID * Photo/caption mode for image-based alerts * Inline keyboard buttons with dynamic URLs * Link preview and notification toggles Telegram captions are limited to 1,024 characters. Standard messages support up to 4,096 characters. ### Webhook POST results to any HTTP endpoint. The system auto-detects Discord webhook URLs and formats the payload as a Discord embed. For all other URLs, the payload is: ```json theme={null} { "automationId": "uuid", "automationName": "My Automation", "result": "The formatted or AI-generated output", "eventPayload": { ... }, "timestamp": "2026-03-11T12:00:00.000Z" } ``` ## Filters Automations inherit the same filtering capabilities as webhooks. You can narrow which events trigger your automation using: * **Wallet address**: track specific traders * **Market / condition ID**: target specific markets * **Event slug**: filter by event * **USD thresholds**: minimum/maximum value bounds * **Probability range**: 0.0 to 1.0 Only events matching all configured filters will trigger the automation. ## Rate Limiting Each automation has a configurable **max runs per hour** (default: 10). When the limit is reached, incoming events are logged with a `rate_limited` status and skipped until the window resets. Adjust the rate limit when creating or editing an automation to balance between coverage and cost. ## Monitoring Runs Every automation execution is logged as a **run** with the following details: | Field | Description | | ------------- | -------------------------------------------------------------- | | Status | `pending`, `running`, `completed`, `failed`, or `rate_limited` | | Result | The AI-generated or template-rendered output text | | Event payload | The raw webhook event that triggered the run | | Duration | Execution time in milliseconds | | Token usage | Input and output tokens consumed (AI mode only) | View run history from the automation detail page in your dashboard. Failed runs include an error message for debugging. ## Managing Automations From the Automations page you can: * **Enable / disable** an automation without deleting it * **Edit** the trigger event, prompts, output config, or rate limit * **Delete** an automation and its associated webhook subscription * **View logs** to see the full chat history for AI-mode runs # Template Syntax Source: https://docs.struct.to/dashboard/automations-syntax Write dynamic templates using Handlebars syntax with built-in formatters and helpers. Automations use [Handlebars](https://handlebarsjs.com/) syntax for templating. This applies to user prompt templates in both AI and Direct processing modes, and to all text fields in Discord and Telegram output configurations. ## Basics Event data is available under the `event` object. Access fields with double curly braces: ``` Market: {{event.market_name}} Trader: {{event.trader_address}} Price: {{event.price}} ``` ## Conditionals ``` {{#if event.market_name}} Market: {{event.market_name}} {{else}} No market data available {{/if}} {{#unless event.error}} Trade successful {{/unless}} ``` ## Loops ``` {{#each event.positions}} - {{this.market_name}}: {{this.side}} at {{this.price}} {{/each}} ``` ## Logical Operators Combine conditions using `and`, `or`, and comparison helpers as sub-expressions: ``` {{#if (and (gt event.size_usd 50000) (eq event.side "Buy"))}} Large buy detected {{/if}} {{#if (or (eq event.side "Buy") (eq event.side "Sell"))}} Trade event {{/if}} ``` ## Formatters Built-in helpers format event data for readability. | Helper | Usage | Output | | ---------------- | --------------------------------------------- | ------------------------------------- | | `formatNumber` | `{{formatNumber event.volume}}` | `2,450,000` | | `formatCurrency` | `{{formatCurrency event.size_usd}}` | `$15,420.50` | | `formatPercent` | `{{formatPercent event.current_probability}}` | `72%` | | `formatDate` | `{{formatDate event.block_timestamp}}` | `Jun 15, 2025` | | `uppercase` | `{{uppercase event.side}}` | `BUY` | | `lowercase` | `{{lowercase event.side}}` | `buy` | | `truncate` | `{{truncate event.description 100}}` | First 100 chars + `...` | | `default` | `{{default event.category "N/A"}}` | Fallback when value is empty | | `math` | `{{math event.price "*" 100}}` | Basic arithmetic (`+`, `-`, `*`, `/`) | | `json` | `{{json event}}` | Pretty-printed JSON | ### Formatter Options Some formatters accept named options: ``` {{formatNumber event.volume decimals=2}} → 2,450,000.00 {{formatCurrency event.size_usd currency="EUR"}} → €15,420.50 {{formatDate event.timestamp format="long"}} → June 15, 2025 ``` ## Comparison Helpers Use these as sub-expressions inside `{{#if}}` blocks: | Helper | Description | | ------ | --------------------- | | `eq` | Equal (`===`) | | `ne` | Not equal (`!==`) | | `gt` | Greater than | | `gte` | Greater than or equal | | `lt` | Less than | | `lte` | Less than or equal | ``` {{#if (gt event.size_usd 100000)}} Whale trade: {{formatCurrency event.size_usd}} {{/if}} ``` ## Event Fields Each webhook event type exposes different fields. You can browse the available fields for any event type from the automation editor in the dashboard. Click **View Event Fields** when configuring your template. See [Webhook Events](/webhooks/getting-started#available-events) for the full list of supported event types. # Automation Templates Source: https://docs.struct.to/dashboard/automations-templates Get started quickly with pre-built automation templates. The template gallery provides ready-to-use automation configurations. Select a template when creating a new automation to pre-fill the event type, processing mode, prompts, and output config. ## AI Templates These use AI processing to analyze event data and generate insights. Evaluates new prediction markets for trading opportunities, assessing market structure, initial probability, and liquidity potential. Analyzes large trades for market impact, breaking down position sizing, sentiment shifts, and short-term price predictions. Investigates sudden probability changes to determine if they are driven by news, whale activity, or suspicious manipulation. Generates comprehensive market summaries when trading volume crosses USD milestones. Analyzes first trades for timing patterns, wallet history, and correlation with non-public information. Monitors markets approaching extreme probabilities near resolution boundaries and identifies mispriced edge opportunities. ## Direct Templates These use Handlebars templating to format and forward data without AI processing. See [Template Syntax](/dashboard/automations-syntax) for the full syntax reference. Forwards raw webhook event JSON to an external system with zero processing overhead. Formats price spikes into readable alerts with previous/current price and volume context. Extracts key trade details (market, trader, side, size, price) into a structured notification. Posts trader portfolio summaries to Discord with realized, unrealized, and total P\&L in embed fields. Displays per-market profit and loss with position side, size, and entry vs. current price. Alerts when a tracked trader enters a new prediction market, showing trade direction and size. Detects unusual volume spikes and posts alerts with change percentage and absolute values. Posts market health indicators including probability, volume, liquidity, open interest, and spread. Streams asset price updates with 24h change and volume data. ## AI-Generated Templates If the pre-built templates don't fit your use case, you can describe what you want in plain language and have AI generate a custom template for you. Use the **Generate Template** option in the automation editor to create a tailored prompt and output configuration from a natural-language description. # Billing Source: https://docs.struct.to/dashboard/billing Monitor usage, manage your plan, and configure payment methods. Billing is managed at the [organisation](/dashboard/organisations) level. Each organisation has its own plan, credit balance, and payment method. ## Plans Struct offers five plans to match your usage needs. All plans include access to every API. | Plan | Price | Credits | API keys | Webhooks | WS connections | | ---------- | -------- | --------- | --------- | --------- | -------------- | | Free | Free | 5,000 | 1 | 10 | 1 | | Hobby | \$49/mo | 100,000 | 3 | 5,000 | 50 | | Startup | \$299/mo | 1,000,000 | 10 | 25,000 | 250 | | Scale | \$999/mo | 5,000,000 | 25 | 100,000 | 1,000 | | Enterprise | Custom | Custom | Unlimited | Unlimited | Unlimited | For full details on rate limits and credit costs, see [Pricing & Ratelimits](/introduction/pricing). ## Changing Your Plan Navigate to the **Billing** section in your organisation's dashboard. Choose the plan you want to switch to and confirm. Upgrades take effect immediately. Downgrades take effect at the end of your current billing cycle. Upgrading mid-cycle will prorate the charge for the remainder of the billing period. ## Usage Tracking The Billing page provides a real-time view of your organisation's credit consumption, including: * **Credits used**: total credits consumed in the current billing cycle * **Credits remaining**: how many credits are left before your limit * **Usage breakdown**: credit consumption split by API endpoint and Websocket connections ## Payment Methods Add or update your payment method from the Billing page. Struct accepts major credit and debit cards. If your payment method fails, your organisation will be downgraded to the Free plan at the end of the billing cycle. Make sure your payment details are up to date to avoid service interruptions. ## Invoices Invoices are generated at the end of each billing cycle and can be downloaded from the Billing page. Each invoice includes a breakdown of your plan charges and any overage fees. # JWT Public Keys Source: https://docs.struct.to/dashboard/jwt-keys Let your end users authenticate directly to the Struct API using JWTs from their own auth provider. JWT public keys let builders expose Struct API access to their end users **without sharing their real `sk_*` API key**. End users authenticate with JWTs from the builder's own auth provider (Privy, Turnkey, Google, Auth0, etc.). Struct only verifies the JWT, never creates or manages it. Each JWT public key (`pk_jwt_*`) is safe to hardcode in frontend bundles. It tells Struct: * Which JWKS URL or inline public key to use to verify the end user's JWT * Which builder organisation to bill the request to * What per-session rate limit to enforce End users never see or interact with the `pk_jwt_*` key. Their only credential is the JWT issued by their own auth provider. ## How It Works ``` Builder dashboard Builder's frontend app Struct API ───────────────────────────────────────────────────────────────────── Create pk_jwt_xxx ──► Hardcode pk_jwt_xxx Configure JWKS URL User logs in via Privy ──► Privy issues JWT ◄── API request with: ──► Verify JWT against JWKS X-Api-Key: pk_jwt_xxx Bill credits to builder's org Authorization: Bearer JWT Apply per-session rate limit ``` ## Setting Up a JWT Public Key JWT public keys are configured from within an API key's settings page. Go to **API Keys** in your organisation's dashboard and select the API key you want to associate with JWT auth. On the API key detail page, click the **Settings** tab. Fill in the JWT public key configuration: * A **name** for the key (e.g. `My App (Privy)`) * A **JWKS URL** *or* an **inline public key** for JWT verification * Optionally, expected **audience** and **issuer** claims * Optionally, a **per-session rate limit** (requests per minute per end user) Your `pk_jwt_*` key is shown once at creation. Embed it in your frontend application. ## Managing Keys From the API key's **Settings** tab you can: * **Update** a key's name, JWKS URL, public key, audience, issuer, rate limit, or enabled status * **Disable** a key to temporarily block all requests using it (takes effect within 60 seconds) * **Delete** a key to permanently invalidate it (takes effect immediately) Updates take effect within 60 seconds as the cached record expires. ## Configuration Fields | Field | Type | Required | Description | | ----------------- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | Human-readable label | | `jwks_url` | string | Either/or | JWKS endpoint URL. Used for RS256, RS384, RS512, ES256, ES384 (Privy, Google, Turnkey, Auth0, etc.) | | `public_key` | string | Either/or | Inline public key (PEM or JWK JSON). Supports RSA, EC, and EdDSA algorithms. Alternative to `jwks_url`; provide one, not both | | `audience` | string | No | If set, the `aud` claim in the user JWT must match this value | | `issuer` | string | No | If set, the `iss` claim in the user JWT must match this value | | `per_session_rpm` | integer | No | Rate limit per end user session (per `sub` claim), in requests per minute. `null` = no per-session limit | Exactly one of `jwks_url` or `public_key` must be provided: not both, not neither. ## Billing Each `pk_jwt_*` key is associated with a standard API key at creation time. All credits and rate limits are billed through that parent API key, so the parent key's rate limit and credit cap apply. End users do not need a Struct account or credits. The `per_session_rpm` is an additional per-user cap applied on top of the parent key's limits, keyed on the `sub` claim from the user JWT. ## Security * A leaked `pk_jwt_*` key cannot be exploited alone; it is useless without a valid user JWT from the configured provider * Struct only stores public verification material (JWKS URLs or public keys), never private keys or signing secrets * Only asymmetric JWT algorithms are supported (RS256, RS384, RS512, ES256, ES384, EdDSA); symmetric secrets (HS256) are not accepted * Disabling or deleting a key takes effect within 60 seconds (cache TTL) * JWKS keys are cached for 5 minutes. If a provider rotates signing keys, the first request with a new `kid` will trigger an immediate re-fetch # Organisations Source: https://docs.struct.to/dashboard/organisations Create and manage organisations to collaborate with your team. Organisations are the top-level container for everything on Struct. API keys, billing, and usage are all scoped to an organisation rather than individual accounts. ## Creating an Organisation Navigate to [struct.to/dashboard](https://struct.to/dashboard) and log in. Click **New Organisation** from the sidebar, enter a name, and confirm. You can create multiple organisations to separate projects, teams, or environments. ## Members & Roles Invite team members to your organisation and assign them roles to control access. | Role | Permissions | | ------ | ------------------------------------------------------------ | | Owner | Full access: manage members, billing, API keys, and settings | | Admin | Manage API keys and members, view billing | | Member | View API keys and usage, access the playground | Each organisation must have at least one owner. To transfer ownership, assign the Owner role to another member before removing yourself. ## Inviting Members Open your organisation settings and select the **Members** tab. Enter the email address of the person you want to invite and select their role. The invitee receives an email with a link to join your organisation. If they don't have a Struct account, they'll be prompted to create one. ## Switching Organisations If you belong to multiple organisations, use the organisation switcher in the sidebar to move between them. Your API keys, usage, and billing will update to reflect the selected organisation. # Playground Source: https://docs.struct.to/dashboard/playground Test Struct API endpoints directly from your browser. The Playground lets you explore and test Struct API endpoints without writing any code. It's built into the dashboard and automatically authenticates requests using your organisation's API key. ## Using the Playground Browse the available API endpoints from the sidebar or search for a specific one. Fill in any required path parameters, query parameters, or request body fields. Hit **Send** to execute the request. The response body, status code, and headers are displayed in real time. ## Features Requests are automatically signed with your active API key, so no manual header setup is needed. Formatted JSON responses with syntax highlighting and the ability to copy the raw output. Recent requests are saved so you can quickly re-run or modify previous calls. Generate ready-to-use code snippets in cURL, JavaScript, and Python for any request. Playground requests consume credits from your organisation's plan just like any other API call. See [Pricing & Ratelimits](/introduction/pricing) for details. # Adding search to your app Source: https://docs.struct.to/guides/adding-search Power typeahead, command palettes, and search bars with one endpoint that returns events, markets, traders, and builders in a single call. `GET /v1/polymarket/search` is a single endpoint that searches across events, markets, traders, and builders at once. One query returns up to four typed result lists, each with its own pagination cursor, which makes it a natural fit for a command palette, a typeahead dropdown, or a global search bar. The `q` parameter accepts a free-text term (minimum 2 characters), a slug, or a wallet address. ## When to use this * Building a command-palette (Cmd-K) overlay that jumps to any event, market, trader, or builder. * Adding a typeahead dropdown that suggests results as the user types. * Resolving a pasted wallet address to a trader profile. * Powering a global search bar that groups hits by category. ## A basic multi-category search Call `client.search.search` with just `q` to search all four categories. Each category comes back as its own array on the response, defaulting to 10 results per category. ```typescript SDK theme={null} const { data } = await client.search.search({ q: "election", }); console.log(data.events); console.log(data.markets); console.log(data.traders); console.log(data.builders); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=election" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={"q": "election"}, ) data = response.json()["data"] events = data["events"] markets = data["markets"] traders = data["traders"] builders = data["builders"] ``` The response is a `SearchResponse` with `events`, `markets`, `traders`, and `builders` arrays (each may be empty or null when a category has no hits), plus the matching `events_pagination`, `markets_pagination`, `traders_pagination`, and `builders_pagination` cursors. Event hits are `PolymarketEvent` objects (`id`, `event_slug`, `title`, `image_url`, `market_count`, ...), market hits are `MarketResponse` objects (`condition_id`, `market_slug`, `question`, `volume_usd`, ...), trader hits are `TraderWithPnl` objects (`address`, `name`, `pseudonym`, `profile_image`, ...), and builder hits are `BuilderMetadata` objects (`builder_code`, `name`, `icon_url`, ...). ## Scoping results with `type` Searching every category costs 1 credit per category. When a surface only needs some of them, pass `type` as a comma-separated list of `events`, `markets`, `traders`, and `builders` to search just those. A markets-only autocomplete, for example, only needs `markets`. ```typescript SDK theme={null} const { data } = await client.search.search({ q: "fed rate", type: "markets,events", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=fed%20rate&type=markets,events" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={"q": "fed rate", "type": "markets,events"}, ) data = response.json()["data"] ``` Categories left out of `type` are omitted from the response, so a request scoped to `markets,events` returns no `traders` or `builders` lists and is billed for two categories instead of four. ## Looking up a wallet by address Passing a `0x` address as `q` performs an exact wallet lookup against the traders category. This is the path to take when a user pastes an address into your search box and you want to resolve it straight to a trader profile. Scope to `type: "traders"` to skip the other categories. ```typescript SDK theme={null} const { data } = await client.search.search({ q: "0x1234567890abcdef1234567890abcdef12345678", type: "traders", }); const trader = data.traders?.[0]; ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=0x1234567890abcdef1234567890abcdef12345678&type=traders" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "q": "0x1234567890abcdef1234567890abcdef12345678", "type": "traders", }, ) data = response.json()["data"] trader = (data["traders"] or [None])[0] ``` A matching address comes back as the first entry in `traders`, with the resolved `address`, `name`, `pseudonym`, and `profile_image` for the wallet. ## Enriching trader hits with PnL By default trader hits carry only profile fields. Set `include_pnl` to `true` to attach a lifetime PnL summary to each trader, which is handy when search results double as a leaderboard preview. This adds 1 credit to the request on top of the per-category cost. ```typescript SDK theme={null} const { data } = await client.search.search({ q: "whale", type: "traders", include_pnl: true, }); const top = data.traders?.[0]; console.log(top?.address, top?.pnl); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=whale&type=traders&include_pnl=true" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={"q": "whale", "type": "traders", "include_pnl": True}, ) data = response.json()["data"] ``` With `include_pnl` enabled, each `TraderWithPnl` in `traders` gains a `pnl` field holding the wallet's lifetime PnL summary. The field is absent when `include_pnl` is not set. ## Sorting and timeframe `sort_by` controls how events and markets are ordered. It accepts `volume` (the default), `relevance`, `liquidity`, `holders`, `txns`, `unique_traders`, and the date and time fields (`creation_date`, `start_date`, `end_date`, `start_time`, `end_time`, `created_time`). Pair it with `sort_dir` (`asc` or `desc`, default `desc`). When you sort by an activity metric such as `volume`, `txns`, or `unique_traders`, `timeframe` selects the window that metric is measured over: `1m`, `5m`, `30m`, `1h`, `6h`, `24h` (the default), `7d`, `30d`, or `lifetime`. ```typescript SDK theme={null} const { data } = await client.search.search({ q: "trump", type: "markets", sort_by: "volume", sort_dir: "desc", timeframe: "7d", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=trump&type=markets&sort_by=volume&sort_dir=desc&timeframe=7d" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "q": "trump", "type": "markets", "sort_by": "volume", "sort_dir": "desc", "timeframe": "7d", }, ) data = response.json()["data"] ``` Ranking the markets list by 7-day volume surfaces what is busy right now, which is usually what a search-as-you-type box should show first. ## Per-category pagination `limit` caps results per category (default 10, max 250), so a single response holds at most `limit` events, `limit` markets, and so on. Each category paginates independently through its own cursor. Read `pagination_key` from a category's pagination block (`data.events_pagination`, `data.markets_pagination`, `data.traders_pagination`, or `data.builders_pagination`) and feed it back as the matching request parameter (`events_pagination_key`, `markets_pagination_key`, `traders_pagination_key`, or `builders_pagination_key`) to fetch the next page of just that category. ```typescript SDK theme={null} const first = await client.search.search({ q: "election", limit: 25 }); const next = await client.search.search({ q: "election", limit: 25, markets_pagination_key: first.data.markets_pagination?.pagination_key ?? undefined, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/search?q=election&limit=25&markets_pagination_key=MARKETS_CURSOR" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests base = {"q": "election", "limit": 25} first = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params=base, ).json()["data"] cursor = (first["markets_pagination"] or {}).get("pagination_key") second = requests.get( "https://api.struct.to/v1/polymarket/search", headers={"X-API-Key": "YOUR_API_KEY"}, params={**base, "markets_pagination_key": cursor}, ).json()["data"] ``` Each `*_pagination` block is a `PaginationMeta` with `has_more` and `pagination_key`. When `has_more` is `false`, that category has no further pages, even if other categories still do. Because the cursors are independent, you can advance markets without re-fetching events, which keeps a category-grouped results panel cheap to load more on demand. # API Cookbook Source: https://docs.struct.to/guides/api-cookbook A task-to-endpoint map for the Struct API. Find the right SDK method and recipe for what you are building without scrolling the full reference. The API is large. This page maps common tasks to the SDK method that does the job and the recipe that walks through it. Every method shown is a method on the [TypeScript SDK](/sdk/overview) client (called as `client.namespace.method(...)`) and maps to a REST endpoint in the [API Reference](/api-reference/getting-started). Reach for the recipe when there is one, the SDK method when there is not. New here? Start with [Fetching all data](/guides/fetching-all-data) for pagination, then jump to the section below that matches what you are building. ## Search and discovery | I want to... | SDK method | Recipe | | --------------------------------------------- | ----------------------------------- | ------------------------------------------------------ | | Search events, markets, traders, and builders | `search.search` | [Adding search](/guides/adding-search) | | Look up a wallet by address | `search.search` (`type: "traders"`) | [Adding search](/guides/adding-search) | | Find markets that just moved | `markets.getPriceJumps` | [Finding market movers](/guides/finding-market-movers) | | See platform-wide momentum | `analytics.getChanges` | [Finding market movers](/guides/finding-market-movers) | ## Markets and prices | I want to... | SDK method | Recipe | | ------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------- | | List or filter markets | `markets.getMarkets` | | | Get one market | `markets.getMarket`, `markets.getMarketBySlug` | | | Price candlesticks for a market or position | `markets.getCandlestick`, `markets.getPositionCandlestick` | [TradingView charts](/guides/tradingview-charts) | | A market's volume over time | `markets.getMarketVolumeChart` | | | Recent trades for a market | `markets.getTrades` | [Copy trading](/guides/copy-trading-pending-or-confirmed-trades) | | Top traders in a market or position | `markets.getMarketTopTraders`, `markets.getPositionTopTraders` | | | Oracle / resolution events | `markets.getOracleEvents` | | | Crypto Up/Down price feed | `assets.getAssetCandlestick`, `assets.getAssetHistory` | [Crypto Up/Down feed](/guides/crypto-up-down-feed) | ## Events and series | I want to... | SDK method | Recipe | | --------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | List or filter events | `events.getEvents` | [Trending events and markets](/guides/real-time-trending-events-and-markets) | | Get one event and its markets | `events.getEvent`, `events.getEventBySlug`, `events.getEventOutcomes` | | | Event price chart and metrics | `events.getEventChart`, `events.getEventMetrics` | | | Work with recurring series | `series.getSeriesList`, `series.getSeriesEvents`, `series.getSeriesOutcomes` | | | Browse markets by tag or category | `tags.getTags`, `tags.getTag` | | | Find category leaders | `tags.getCategoryTopTraders` | | ## Traders and PnL | I want to... | SDK method | Recipe | | ------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | A trader's open and closed positions | `trader.getTraderOutcomePnl` | [Open and closed positions](/guides/trader-open-and-closed-positions) | | Global / market / category PnL for a wallet | `trader.getGlobalPnl`, `trader.getTraderMarketPnl`, `trader.getTraderCategoryPnl` | [Live PnL dashboard](/guides/live-trader-pnl-dashboard) | | A wallet's combo (parlay) positions, expanded into legs | `GET /v1/polymarket/trader/pnl/{address}/combos`, `.../combo` | [Combo PnL](/guides/trader-combo-pnl) | | PnL candlesticks and daily heatmap | `trader.getTraderPnlCandles`, `trader.getTraderPnlCalendar` | [PnL history charts](/guides/trader-pnl-history-charts) | | Best/worst periods and drawdown | `trader.getTraderPnlPeriods`, `trader.getTraderPnlRisk` | [PnL history charts](/guides/trader-pnl-history-charts) | | A wallet's biggest wins and losses | `trader.getTopTradesMarkets` | [PnL history charts](/guides/trader-pnl-history-charts) | | Position exits (win/loss markers) | `trader.getTraderPnlExits` | [PnL chart with exit markers](/guides/pnl-chart-with-exit-markers) | | Enrich wallets with profiles | `trader.getTraderProfile`, `trader.getTraderProfilesBatch` | | | A trader's trades or volume | `trader.getTraderTrades`, `trader.getTraderVolumeChart` | | ## Holders | I want to... | SDK method | Recipe | | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Current holders of a market or position | `holders.getMarketHolders`, `holders.getPositionHolders` | | | Holder count history | `holders.getMarketHoldersHistory`, `holders.getPositionHoldersHistory`, `holders.getEventHoldersHistory` | [Holder metrics tracking](/guides/holder-metrics-tracking) | ## Order book and liquidity | I want to... | SDK method | Recipe | | --------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------- | | Current order book | `orderBook.getOrderBook`, `orderBook.getMarketOrderBook` | [CLOB liquidity dashboard](/guides/clob-liquidity-markets-dashboard) | | Order book or spread history | `orderBook.getOrderBookHistory`, `orderBook.getSpreadHistory` | | | Bond zone (close-to-bond) markets | `bonds.getBonds` | [Bond zone alerts](/guides/bond-zone-alerts) | ## Builders and attribution | I want to... | SDK method | Recipe | | -------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | | Trades routed via a builder code | `markets.getTrades` (`builder_codes`) | [Trades by builder code](/guides/fetching-trades-by-builder-code) | | Builder fee revenue over time | `builders.getBuilderTimeseries`, `builders.getBuilderFees` | [Builder revenue dashboard](/guides/builder-revenue-dashboard) | | Your top traders and retention | `builders.getBuilderTopTraders`, `builders.getBuilderRetention` | [Builder revenue dashboard](/guides/builder-revenue-dashboard) | | Volume concentration | `builders.getBuilderConcentration` | [Builder revenue dashboard](/guides/builder-revenue-dashboard) | | Rank and search builders | `builders.getBuilders`, `builders.searchBuilders` | | ## Platform analytics | I want to... | SDK method | Recipe | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | Platform-wide percentage changes | `analytics.getChanges` | [Finding market movers](/guides/finding-market-movers) | | Activity buckets over time | `analytics.getDeltas`, `analytics.getTimeseries` | [Finding market movers](/guides/finding-market-movers) | | Per-entity time series | `analytics.getMarketTimeseries`, `analytics.getEventTimeseries`, `analytics.getTraderTimeseries`, `analytics.getTagTimeseries` | | ## Realtime For push instead of polling, every domain above has a websocket and webhook counterpart. | I want to... | Where | | --------------------------------------------------------- | ---------------------------------------------------------------------- | | Stream raw data (trades, prices, PnL, positions, holders) | [Websocket rooms](/websockets/getting-started) | | Receive event notifications over a socket | [Alerts](/websockets/alerts/getting-started) | | Receive event notifications over HTTP | [Webhooks](/webhooks/getting-started) | | Manage webhook subscriptions | `webhooks.create`, `webhooks.list`, `webhooks.update`, `webhooks.test` | # Best Practices Source: https://docs.struct.to/guides/best-practices How to build fast, resilient, cost-efficient integrations on top of Struct. A short, opinionated guide to getting the most out of Struct. The patterns below cover latency, cost, resilience, and security in roughly that order. ## Take advantage of pre-computed data Struct's defining feature is that every derived value (PnL, leaderboards, positions, market metrics, event metrics, tag metrics, condition metrics) is **materialised on write**, not computed on read. Rows are updated as on-chain events arrive, never on a periodic cycle, so values stay current with the chain. A query that would otherwise scan hundreds of thousands of trades is a single-row lookup. Every pattern below flows from that: * **Stream metrics, not trades, when you want aggregates.** If your UI shows volume, holders, or trader counts, subscribe to `polymarket_market_metrics` (or event/position/tag/condition metrics) instead of `polymarket_trades` and aggregating yourself. One materialised update per change beats N raw trade messages per change, often by 10-100x. * **Use alerts for "wake me when X" logic.** If you only care about probability moves above a threshold, a whale trade, or a volume milestone, subscribe to the matching alert instead of a raw stream. Alerts fire only on the crossing, so you stop paying for the messages your code would have filtered out anyway. * **Pace the snapshot streams to your UI.** `polymarket_markets_stream` and `polymarket_events_stream` deliver fresh snapshots at your chosen `interval_ms`. Tune the interval to what you actually render (1000ms for a live table, 5000ms for a sidebar). The bill scales linearly with the rate. * **Narrow filters as your UI narrows.** A subscription without filters on a busy room delivers every upstream event. After a user picks a market or a watchlist shrinks, send a fresh `subscribe` to drop everything you no longer care about. Wider filters cost more, full stop. ## Stream, don't poll Anything you'd refresh more than once a minute should be a websocket subscription, not a REST poll. Server-side filtering means you only pay for the messages you actually consume, and push latency is in single-digit milliseconds. | If you're polling… | Subscribe to… | | ------------------------------ | ----------------------------------------------- | | Markets list every few seconds | `polymarket_markets_stream` | | Trader PnL every minute | `polymarket_trader_pnl` | | Order book snapshots | `polymarket_order_book` | | Trade history for a market | `polymarket_trades` (filter on `condition_ids`) | | Crypto prices for an asset | `polymarket_asset_prices` | See [WebSockets](/websockets/getting-started) for the full room catalogue. ## Pool subscriptions on one connection A single websocket can subscribe to every room. The 1-credit connection hold is per-connect, so: * **One socket per process** is usually enough for backend workloads. * **One socket per logged-in user** is enough for in-app alerts and per-user dashboards. * Avoid opening, closing, and reopening sockets in a tight loop. Every reconnect re-incurs the hold. ## Filter at subscribe time Every room takes filter fields on subscribe. Server-side filtering is free; once a message arrives at your client you've already been billed for it. ```typescript SDK theme={null} await ws.subscribe("polymarket_trades", { condition_ids: ["0xabc..."], trade_types: ["OrderFilled"], }); ``` ```json Raw WebSocket theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "condition_ids": ["0xabc..."], "trade_types": ["OrderFilled"] } } } ``` If you need to widen a subscription, send a fresh `subscribe` message; the new filters replace the previous set. ## Reconnect with exponential backoff Subscriptions are bound to the connection. When the socket drops, you must reconnect and resubscribe. * Use exponential backoff with jitter. Start around 500ms, double on each attempt, cap at 30s. * Never retry on auth failures (close codes `1008` and `4001`). Fix credentials first. * Reset the backoff counter on a successful connect. The TypeScript SDK does all of this for you, including replaying every active subscription on reconnect. See [SDK WebSockets](/sdk/websockets#reconnection-and-replay). ## Use JWT public keys in the browser `sk_live_*` keys grant full org access. Never ship them in a frontend bundle, mobile app, or anywhere a user can read them. For client-side calls, mint a `pk_jwt_*` public key in the dashboard, point it at your auth provider's JWKS URL, and pass each user's JWT alongside the key. The key is safe to embed in your bundle, and per-user rate limits keyed on the JWT `sub` claim prevent one user from burning your whole quota. See [Authentication](/introduction/authentication). ## Handle pagination cleanly List endpoints are cursor-paginated with `pagination_key`. Iterate until `pagination.has_more` is `false`. ```typescript theme={null} for await (const market of client.markets.list({ limit: 200 })) { // ... } ``` The TypeScript SDK exposes an async iterator on every paginated endpoint. See [Pagination](/sdk/pagination). ## Cache long-lived data Market metadata (slug, image, outcomes), event metadata, and tag lists rarely change. Cache them in your application layer for at least a few minutes; you can refresh on a websocket event or a TTL whichever you prefer. Volatile values (price, PnL, positions, volume) should not be cached; subscribe to the matching room instead. ## Pick the right surface for the access pattern Most cost regressions come from using the wrong product, not the wrong filter. Match the surface to how often you need the data and how you react to it. | You need to… | Use | Why | | ------------------------------------- | -------------------------- | --------------------------------------------------- | | Read a single value on demand | REST | 1 credit, single-row lookup. | | Render a live view of changing values | WS metrics rooms | Pre-computed deltas, no client-side aggregation. | | React to a threshold crossing | Alerts or webhooks | Fires only on the event you care about. | | Backfill or paginate history | REST with `pagination_key` | Cursor pagination, batch-friendly. | | Receive an event when you're offline | Webhooks | Queued and retried server-side; no socket required. | Per-message rates also vary by room. A higher per-message rate often correlates with lower frequency: Trader PnL costs 0.1 credits per message but only fires when a tracked trader actually trades, while Order Book at 0.001 credits per message can produce thousands of updates per minute on an active market. Don't pick by sticker price alone, model the expected message rate first. See [WebSocket Pricing](/websockets/pricing) for the rate card and worked examples. ## Verify webhook signatures Every webhook delivery is signed with HMAC SHA-256 using the secret shown when you create the webhook. Always verify the signature on receipt before trusting the payload. Rotate the secret when staff leave or if you suspect a leak. See [Webhooks](/webhooks/getting-started) for the verification snippet. # Bond-zone alerts Source: https://docs.struct.to/guides/bond-zone-alerts Get notified the moment an outcome becomes near-certain, trading at the extremes of the probability range, over webhooks or websockets. A "bond zone" is the near-certain end of a market: an outcome trading at, say, ≥ 95¢ (almost certain to win) or ≤ 5¢ (almost certain to lose). The `close_to_bond` event fires when a trade prints inside a zone you define, so you can react to outcomes that are effectively resolved before the oracle settles them. The same event and filters are available two ways: | Transport | How it arrives | Reference | | --------- | ----------------------------- | --------------------------------------------------------- | | Webhook | HTTP `POST` to your endpoint | [`close-to-bond` callback](/webhooks/close-to-bond) | | Websocket | Pushed over a live connection | [`close_to_bond` alert](/websockets/alerts/close-to-bond) | ## When to use this * **Resolution front-running:** surface markets the crowd already treats as decided, ahead of on-chain settlement. * **Risk / exit signals:** alert when a position you hold flips to near-certain-loss so you can exit the remaining size. * **Mispricing scans:** watch one side of a market for prints at the opposite extreme of where you think it should trade. ## Defining the bond zone The bond zone is defined entirely by how you combine `min_price` and `max_price`. The **relationship between the two values** selects one of four modes: | You set | Mode | Fires when | `bond_side` | | -------------------------- | ---------------- | ---------------------------------------------- | ------------------- | | `min_price` only | Single high edge | `price ≥ min_price` | `"high"` | | `max_price` only | Single low edge | `price ≤ max_price` | `"low"` | | Both, with **`min < max`** | Bounded range | `min_price ≤ price ≤ max_price` | `"high"` | | Both, with **`min > max`** | Two edges | `price ≥ min_price` **or** `price ≤ max_price` | `"high"` or `"low"` | **`min < max` is a band, not a bond.** If you set `min_price: 0.75` and `max_price: 0.90`, you do **not** get "fires above 90% or below 75%". Because `min < max`, it switches to bounded-range mode and fires only when the price lands **inside** the 75–90% band. To alert on the two near-certain extremes, you must set `min > max` (see below). ## Alerting on near-certain outcomes To get notified when an outcome becomes near-certain in **either** direction (at or above 90% **or** at or below 10%), set `min_price` **higher** than `max_price`: ```json theme={null} { "min_price": 0.90, "max_price": 0.10 } ``` Because `0.90 > 0.10`, this is read as two separate edges. A trade at 96¢ fires with `bond_side: "high"`; a trade at 4¢ fires with `bond_side: "low"`. For a single edge, set just one of the two. ## How price is read * The traded position's own `price` is used: the price of the exact outcome token (`position_id`) that printed. * Trades at a price of exactly `0` or `1` are skipped, since there is no remaining risk to alert on. * `price` is on a `0.0`–`1.0` scale (so 95¢ = `0.95`). ## Picking the right side On a binary market, "YES at ≤10%" and "NO at ≥90%" are the **same** event priced from opposite tokens. If you add `position_outcome_indices: [0]` you will only ever see trades that print on the **Yes/Up** token (index `0`); trades on the No token (index `1`) won't fire even when they hit the same bond zone. Omit `position_outcome_indices` to catch the zone regardless of which side the trade prints on. ## Examples A near-certain-win alert for one specific market, Yes side only: ```json theme={null} { "condition_ids": ["0x19b3cbe24efa387cac21271dd869cb1121432cd212308f3a6e3ec2f7a8614b9d"], "min_price": 0.95, "position_outcome_indices": [0] } ``` Near-certain wins across whole categories, scoped by `tags`: ```json theme={null} { "min_price": 0.95, "tags": ["Sports", "Crypto Prices"], "position_outcome_indices": [0] } ``` `tags` matches a market's tags or its category by display label (for example `"Sports"`, `"Crypto Prices"`), case-insensitive. Both extremes, scoped to the Yes/Up token so each event alerts once: ```json theme={null} { "min_price": 0.90, "max_price": 0.10, "position_outcome_indices": [0], "exclude_shortterm_markets": true } ``` **Two-edge mode (`min_price` > `max_price`) fires from both tokens.** On a near-certain market the Yes token near 90¢ triggers `bond_side: "high"` and the No token near 10¢ triggers `bond_side: "low"`, so each event arrives as two separate deliveries. Add `position_outcome_indices: [0]` (Yes/Up only) to watch one side and get one delivery per extreme. ## Reading the payload Every delivery tells you which zone was hit and the threshold it breached: * `bond_side`: `"high"` (near-certain) or `"low"` (near-zero). * `threshold`: the filter value that was breached. In bounded-range mode this is `min_price`. * `price`: the value that triggered the alert, on a `0.0`–`1.0` scale. See the full field list on the [websocket alert page](/websockets/alerts/close-to-bond#response). ## Available filters | Filter | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `min_price` | number | High-edge / lower bound of the zone, `0.0`–`1.0`. | | `max_price` | number | Low-edge / upper bound of the zone, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets (max 500). | | `position_ids` | string\[] | Restrict to specific outcome tokens (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `position_outcome_indices` | number\[] | Restrict by outcome index: `0` (Yes/Up) or `1` (No). | | `event_slugs` | string\[] | Restrict to specific events (max 500). | | `tags` | string\[] | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500). | | `series_slugs` | string\[] | Restrict to markets in any of these series by slug, case-insensitive (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | At least one of `min_price` or `max_price` is required. Without either, every trade in every market would fire. **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. # Building a builder revenue dashboard Source: https://docs.struct.to/guides/builder-revenue-dashboard Assemble platform totals, revenue over time, top traders, retention cohorts, and volume concentration for a single Polymarket builder code using the Builders analytics endpoints. The Builders namespace exposes aggregated analytics for every Polymarket `builder_code`, the 0x app or integrator code attached to routed order flow. This guide stitches those endpoints into a single dashboard: headline KPIs, a revenue chart, a top-traders leaderboard, retention cohorts, and a concentration breakdown, plus the current fee configuration and its change log. For raw, per-trade attribution rather than aggregates, see [Fetching trades by builder code](/guides/fetching-trades-by-builder-code). ## When to use this * Giving a partner or integrator a self-serve revenue and engagement dashboard. * Tracking builder fees earned over time and how fee-rate changes affected them. * Understanding who a builder's biggest traders are and how concentrated their volume is. * Measuring how well a builder retains traders after their first day. Every response below is wrapped in an envelope, so the payload lives under `.data`. With the SDK, destructure `const { data } = await client.builders...`. With cURL or Python, read `response.json()["data"]`. ## Platform-wide totals Start with `getGlobal` for platform context across all builders. The `timeframe` parameter accepts `"lifetime"`, `"24h"`, `"1d"`, `"7d"`, `"30d"`, or `"1mo"`. Use these figures as the baseline a single builder is measured against. ```typescript SDK theme={null} const { data: global } = await client.builders.getGlobal({ timeframe: "30d", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/global?timeframe=30d" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/builders/global", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "30d"}, ) global_totals = response.json()["data"] ``` The `BuilderGlobalLatestRow` payload returns `volume_usd`, `buy_volume_usd`, `sell_volume_usd`, `unique_traders`, `txn_count`, `fees_usd`, `builder_fees`, and `distinct_builders`, along with `ts` and `block` marking when the snapshot was taken. ## Revenue over time `getBuilderTimeseries` returns one row per time bucket so you can plot a builder's revenue trend. The `resolution` parameter accepts `"60"` and `"240"` (minutes), `"D"` or `"1D"` (daily), `"W"` or `"1W"` (weekly), and `"M"` or `"1M"` (monthly), defaulting to `"60"`. Bound the range with `from`, `to`, or `count_back`. ```typescript SDK theme={null} const { data: series } = await client.builders.getBuilderTimeseries({ builder_code: "0xBUILDER", resolution: "D", count_back: 90, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/analytics/timeseries?resolution=D&count_back=90" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/builders/0xBUILDER/analytics/timeseries", headers={"X-API-Key": "YOUR_API_KEY"}, params={"resolution": "D", "count_back": 90}, ) series = response.json()["data"] ``` Each `BuilderTimeBucketRow` uses compact single-letter keys to keep the payload small. They map as follows: | Key | Meaning | | ---- | ------------------- | | `t` | Bucket timestamp | | `v` | Volume in USD | | `bv` | Buy volume in USD | | `sv` | Sell volume in USD | | `tc` | Transaction count | | `f` | Total fees in USD | | `bf` | Builder fees in USD | | `ut` | Unique traders | For a revenue chart, plot `bf` (builder fees earned) over `t`, and overlay `f` (total fees) or `v` (volume) for context. The most recent row's `bf` doubles as a live "builder fees this bucket" KPI for the dashboard header. ## Top traders `getBuilderTopTraders` ranks the wallets driving a builder's flow. Sort with `sort_by`, one of `"volume"`, `"txns"`, `"fees"`, or `"builder_fees"`, and set `sort_desc` to control direction. `limit` defaults to 10 and caps at 250. The optional `timeframe` scopes the window. ```typescript SDK theme={null} const { data: traders } = await client.builders.getBuilderTopTraders({ builder_code: "0xBUILDER", sort_by: "builder_fees", sort_desc: true, limit: 25, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/top-traders?sort_by=builder_fees&sort_desc=true&limit=25" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/builders/0xBUILDER/top-traders", headers={"X-API-Key": "YOUR_API_KEY"}, params={"sort_by": "builder_fees", "sort_desc": True, "limit": 25}, ) traders = response.json()["data"] ``` Each `TopTraderRow` carries the `trader` profile plus `volume_usd`, `txn_count`, `fees_usd`, and `builder_fees`, which map directly onto a leaderboard table. ## Retention cohorts `getBuilderRetention` groups traders by the day they first traded through the builder, then reports how many came back. Bound the cohorts with the optional `from` and `to` parameters. ```typescript SDK theme={null} const { data: cohorts } = await client.builders.getBuilderRetention({ builder_code: "0xBUILDER", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/retention" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/builders/0xBUILDER/retention", headers={"X-API-Key": "YOUR_API_KEY"}, ) cohorts = response.json()["data"] ``` Each `CohortRetentionRow` reports `cohort_day` (epoch seconds at UTC midnight), `cohort_size`, a `retained` object with absolute counts (`D1`, `D7`, `D30`), and a `retention` object with the same keys expressed as fractions. Render `retention` as a cohort grid where rows are `cohort_day` and columns are the D1 / D7 / D30 windows. ## Volume concentration `getBuilderConcentration` shows how much of a builder's volume comes from its largest wallets, a quick read on whether revenue is broad-based or dependent on a few accounts. The optional `timeframe` scopes the calculation. ```typescript SDK theme={null} const { data: concentration } = await client.builders.getBuilderConcentration({ builder_code: "0xBUILDER", timeframe: "30d", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/concentration?timeframe=30d" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/builders/0xBUILDER/concentration", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "30d"}, ) concentration = response.json()["data"] ``` The `ConcentrationResponse` returns `total_volume_usd` and `total_traders` alongside `top1_volume_usd`, `top10_volume_usd`, and `top100_volume_usd`, plus the matching `top1_share`, `top10_share`, and `top100_share` fractions. The share fields are ideal for a concentration bar or a single "top 10 wallets drive X percent of volume" stat. ## Fee configuration and history Two endpoints round out the dashboard. `getBuilderFees` returns the builder's current fee rates, and `getBuilderFeesHistory` returns the change log so you can annotate the revenue chart with rate changes. On the history call, `limit` defaults to 100 and caps at 1000. ```typescript SDK theme={null} const { data: fees } = await client.builders.getBuilderFees({ builder_code: "0xBUILDER", }); const { data: feeHistory } = await client.builders.getBuilderFeesHistory({ builder_code: "0xBUILDER", limit: 100, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/fees" \ -H "X-API-Key: YOUR_API_KEY" curl "https://api.struct.to/v1/polymarket/builders/0xBUILDER/fees/history?limit=100" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests headers = {"X-API-Key": "YOUR_API_KEY"} base = "https://api.struct.to/v1/polymarket/builders/0xBUILDER" fees = requests.get(f"{base}/fees", headers=headers).json()["data"] fee_history = requests.get( f"{base}/fees/history", headers=headers, params={"limit": 100}, ).json()["data"] ``` `getBuilderFees` returns a `BuilderFeeRate` with `code`, `builder_maker_fee_rate_bps`, `builder_taker_fee_rate_bps`, and `enabled`. Rates are in basis points. Each `BuilderFeeRateHistoryEntry` repeats those fields and adds `observed_at` (epoch seconds), so the entries form a timeline of every rate change. For per-trade rows behind these aggregates, pair this dashboard with [Fetching trades by builder code](/guides/fetching-trades-by-builder-code). # Ranking markets by CLOB rewards Source: https://docs.struct.to/guides/clob-liquidity-markets-dashboard Surface markets currently paying CLOB rewards, sorted by combined daily rate, for makers shopping for liquidity programs. The Polymarket CLOB pays maker rewards on a curated set of markets. Each rewarded market carries one or more reward configs (`clob_rewards[]`), each with a daily rate, a max spread, and a min order size. Struct exposes these through the markets list (filter `has_rewards=true`, sort `sort_by=rewards`) and via the markets stream and CLOB rewards rooms for live updates. ## When to use this * Market-maker tooling: a leaderboard of "where can I earn the most rebates today?" * Reward eligibility checks: filter to markets with `rewards_max_spread` your strategy can hit. * Sponsor monitoring: track new sponsors joining or rates changing on a watched market. ## Step 1: list reward-bearing markets `has_rewards=true` keeps only markets with at least one active reward config; `sort_by=rewards` orders the response by combined daily rate descending. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: markets } = await client.markets.getMarkets({ has_rewards: true, sort_by: "rewards", limit: 50, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market?has_rewards=true&sort_by=rewards&limit=50" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests markets = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, params={"has_rewards": "true", "sort_by": "rewards", "limit": 50}, ).json()["data"] ``` ## Step 2: read the reward fields Each market row carries a top-level `total_daily_rate` (the combined daily rate across all sponsors on that market) and a `clob_rewards[]` array with the per-config breakdown: | Field | Type | Description | | ------------------------------------- | ----------------- | ----------------------------------------------- | | `total_daily_rate` | `number \| null` | Top-level: combined daily rate across sponsors. | | `clob_rewards[].rewards_daily_rate` | `number \| null` | Daily rate for this config. | | `clob_rewards[].native_daily_rate` | `number \| null` | Polymarket native rate. | | `clob_rewards[].sponsored_daily_rate` | `number \| null` | Sponsor-funded rate. | | `clob_rewards[].rewards_max_spread` | `number \| null` | Max eligible spread (probability units). | | `clob_rewards[].rewards_min_size` | `number \| null` | Min eligible order size (USD). | | `clob_rewards[].start_date` | `string \| null` | ISO date the reward starts. | | `clob_rewards[].end_date` | `string \| null` | ISO date the reward ends. | | `clob_rewards[].sponsors_count` | `integer \| null` | Active sponsor count. | The combined `total_daily_rate` is the right number for the dashboard's primary sort. The per-config breakdown is what you show on the row's expanded view. ## Step 3: keep the dashboard fresh Two paths, depending on freshness needs: ### Coarse: re-poll on an interval ```typescript theme={null} setInterval(async () => { const { data } = await client.markets.getMarkets({ has_rewards: true, sort_by: "rewards", limit: 50 }); renderDashboard(data); }, 60_000); ``` ### Live: subscribe to the markets stream `polymarket_markets_stream` accepts the same `has_rewards` filter and pushes full market rows whenever they change. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_markets_stream", { interval_ms: 1000, mode: "filter", filter: { has_rewards: true }, }); const byCondition = new Map(); for (const row of markets) byCondition.set(row.condition_id, row); ws.on("markets_stream_update", (event) => { for (const row of event.data) byCondition.set(row.condition_id, row); const sorted = [...byCondition.values()].sort( (a, b) => (b.total_daily_rate ?? 0) - (a.total_daily_rate ?? 0), ); renderDashboard(sorted); }); ``` ```bash cURL theme={null} > {"type":"room_message","payload":{"room_id":"polymarket_markets_stream","message":{"action":"subscribe","interval_ms":1000,"mode":"filter","filter":{"has_rewards":true}}}} ``` ```python Python theme={null} await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_markets_stream", "message": { "action": "subscribe", "interval_ms": 1000, "mode": "filter", "filter": {"has_rewards": True}, }, }, })) ``` The stream only pushes rows that actually changed since the last flush, so re-sorting by `total_daily_rate` runs in bounded time per tick. ## Step 4: stream raw reward config changes For sponsor-level events (a new sponsor joining a market, a rate change, an end date hit) subscribe to `polymarket_clob_rewards`. Each `clob_rewards_update` carries the full reward config so you can merge into a per-market reward map. ```typescript theme={null} await ws.subscribe("polymarket_clob_rewards", { condition_ids: ["0xabc123..."], }); ws.on("clob_rewards_update", (event) => { upsertRewardConfig(event.condition_id, event.reward); }); ``` See [CLOB Rewards](/websockets/rooms/clob-rewards) for the full config shape. ## Common combinations | Goal | Parameters | | ------------------------------------ | --------------------------------------------------------------------- | | Top reward markets right now | `has_rewards=true&sort_by=rewards&limit=50` | | High-liquidity rewarded markets only | `has_rewards=true&min_liquidity=50000&sort_by=rewards` | | Crypto rewards only | `has_rewards=true&categories=crypto&sort_by=rewards` | | High-volume rewarded markets | `has_rewards=true&min_volume=100000&timeframe=24h` | | Track specific markets | `polymarket_markets_stream` with `mode=ids` and `condition_ids=[...]` | ## Follow-on To check whether your own quotes are inside the reward band on the markets you maker on, subscribe to [`polymarket_order_book`](/websockets/rooms/order-book) for the same `condition_ids`. Compare the live spread to each market's `rewards_max_spread` and flag rows where you're out of band. # Mirroring a trader from the mempool Source: https://docs.struct.to/guides/copy-trading-pending-or-confirmed-trades Mirror a target trader's order flow in real time using the trades room's status filter for mempool (pending) detection or block-confirmed signals. The `polymarket_trades` room exposes a `status` filter with three values: `confirmed` (default, post-block), `pending` (mempool, pre-confirmation), or `all` (both). Pending trades arrive seconds before they land on-chain, so a copy-trading pipeline can react before the original fill is confirmed and reconcile against the confirmed event afterwards. ## When to use this * **`pending`**: fastest reaction, mirrors a watched wallet from the mempool. Trades may not land, so size mirrors conservatively. * **`confirmed`**: safe but slower. Use when you only want to react to fills that actually settled. * **`all`**: hybrid pipelines that act on pending and reconcile when the confirmed event arrives. For the underlying schema, see [WebSockets: trades room](/websockets/rooms/trades). ## Subscribe to a target trader's trades Pass `traders` (lowercase 0x-prefixed) and `status` to `polymarket_trades`. The room accepts up to 500 total filters per client. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trades", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], status: "pending", trade_types: ["OrderFilled", "OrdersMatched"], }); ws.on("trade_stream_update", (event) => { if (event.data.trade_type !== "OrderFilled" && event.data.trade_type !== "OrdersMatched") return; const { trader, side, condition_id, position_id, outcome, usd_amount, price } = event.data; console.log(event.status, trader.address, side, outcome, usd_amount, price); }); ``` ```bash cURL theme={null} wscat -c "wss://api.struct.to/ws?api-key=YOUR_API_KEY" > {"type":"join_room","payload":{"room_id":"polymarket_trades"}} > {"type":"room_message","payload":{"room_id":"polymarket_trades","message":{"action":"subscribe","traders":["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"],"status":"pending","trade_types":["OrderFilled","OrdersMatched"]}}} ``` ```python Python theme={null} import json import websockets async with websockets.connect("wss://api.struct.to/ws?api-key=YOUR_API_KEY") as ws: await ws.send(json.dumps({"type": "join_room", "payload": {"room_id": "polymarket_trades"}})) await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], "status": "pending", "trade_types": ["OrderFilled", "OrdersMatched"], }, }, })) async for raw in ws: msg = json.loads(raw) if msg.get("type") == "trade_stream_update": print(msg["status"], msg["data"]["trader"]["address"], msg["data"].get("usd_amount")) ``` ## Reading a `trade_stream_update` The payload is a discriminated union narrowed by `trade_type`. For copy trading, the two types you care about are `OrderFilled` and `OrdersMatched`. Both expose `condition_id`, `position_id`, `outcome`, `outcome_index`, `side` (`Buy` / `Sell`), `usd_amount`, `shares_amount`, `price`, and `probability`. Pending trades omit fields that only exist after a block lands: `block`, `confirmed_at`, `log_index`, `block_index`, `order_hash`, `taker`, `fee`, `fee_shares`, `fee_pct`. They include `received_at` (Unix milliseconds) instead. Use `received_at` for ordering pending events. See [Trade Types](/sdk/trade-types) for the full discriminated union. ## A copy-mirror loop Once a watched trader's `OrderFilled` arrives, place a proportional order on the same `condition_id` and `outcome` via your own execution path. A minimal sketch: ```typescript theme={null} const SIZE_RATIO = 0.05; ws.on("trade_stream_update", async (event) => { if (event.status === "pending" && (event.data.trade_type === "OrderFilled" || event.data.trade_type === "OrdersMatched")) { await placeMirrorOrder({ condition_id: event.data.condition_id, position_id: event.data.position_id, side: event.data.side, usd_amount: event.data.usd_amount * SIZE_RATIO, max_price: event.data.side === "Buy" ? event.data.price * 1.02 : event.data.price * 0.98, }); } }); ``` The room itself does not filter by trade size; clamp `usd_amount` client-side if you only want to mirror whale fills. ## Common combinations | Goal | Subscribe payload | | ----------------------------------------------- | ------------------------------------------------------------------------------------ | | Mirror one trader from the mempool | `traders=[0x...]&status=pending` | | Confirmed-only mirror for safer fills | `traders=[0x...]&status=confirmed` | | Hybrid (act on pending, reconcile on confirmed) | `traders=[0x...]&status=all` | | Watch multiple wallets | `traders=[0x...,0x...]&trade_types=[OrderFilled,OrdersMatched]` | | Whale-only mirror (size in client) | `traders=[0x...]&status=all`, then drop trades below a USD threshold in your handler | ## Reconciling pending against confirmed With `status: "all"` you receive each trade up to twice: once as pending, once as confirmed. Key by trade `id` (or `hash`) and track which side has arrived. Pending events that never see a confirmed counterpart within a window (typically 30 to 60 seconds on Polygon) should be treated as dropped. ```typescript theme={null} const seen = new Map(); ws.on("trade_stream_update", (event) => { if (event.data.trade_type !== "OrderFilled" && event.data.trade_type !== "OrdersMatched") return; const id = event.data.id; const entry = seen.get(id) ?? { pending: false, confirmed: false }; if (event.status === "pending") entry.pending = true; if (event.status === "confirmed") entry.confirmed = true; seen.set(id, entry); }); ``` ## Verifying the trader actually held the position After mirroring, confirm the position landed on the trader's book by subscribing to [`polymarket_trader_positions`](/websockets/rooms/trader-positions) with the same `traders` filter. The next `trader_position_update` for that wallet will reflect the new `current_shares_balance` once the trade clears. # Crypto Up/Down feed Source: https://docs.struct.to/guides/crypto-up-down-feed Build on Crypto Up/Down markets with historical windows, OHLC price charts, live ticks, and window resolutions. Polymarket's Crypto Up/Down markets resolve over fixed-length windows: the window's open price is locked when it starts, the close price is locked when it ends, and the market resolves UP if close > open or DOWN otherwise. Struct exposes the resolution data behind these markets through three sources, all keyed off `(symbol, variant, start_time)`: | Need | Source | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | Past window results (open, close, outcome) | `client.assets.getAssetHistory` | | Historical OHLC price chart for the asset | `client.assets.getAssetCandlestick` | | Live in-window asset price | [`polymarket_asset_prices`](/websockets/rooms/asset-prices) (`asset_price_tick`) | | Window open and close events | [`polymarket_asset_window_updates`](/websockets/rooms/asset-window-updates) (`asset_price_window_update`) | Each resolution window carries only two prices: the open locked at `start_time` and the close locked at `end_time`. When you want a full price chart of the underlying asset to render behind the live indicator, use the [asset candlestick endpoint](#charting-the-underlying-price) for OHLC bars at standard resolutions. ## When to use this * Up/Down market dashboards: a row per (asset, variant) showing the current window's open price, the live spot price, and whether spot is currently above or below open. * Resolution monitors: react when a window closes and a market resolves. * Past-outcome leaderboards: "BTC 1h windows over the last 24h, which closed UP vs DOWN". ## How a window resolves Every Up/Down market is keyed by `(symbol, variant, start_time)`: * `start_time` and `end_time` are Unix milliseconds, `end_time - start_time` matches the `variant` length. * At `start_time`, an `update_type: "open"` event fires with `open_price` locked in. * While the window is live, `asset_price_tick` events for the same `symbol` give you spot. * At `end_time`, an `update_type: "close"` event fires with `close_price` locked in. The market is now resolved: UP if `close_price > open_price`, DOWN otherwise. Supported `variant` values across the price-history endpoint and the window-updates room: `5m`, `15m`, `1h`, `4h`, `1d`, `24h`. ## Step 1: backfill past windows `client.assets.getAssetHistory` returns past resolution windows for a `(symbol, variant)` pair. Each row carries the locked `open_price` and `close_price` for that window, which is what determines the UP / DOWN outcome. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: history } = await client.assets.getAssetHistory({ asset_symbol: "BTC", variant: "1h", }); const past = history.map((row) => ({ start_time: row.start_time, end_time: row.end_time, open_price: row.open_price, close_price: row.close_price, outcome: row.close_price > row.open_price ? "UP" : "DOWN", })); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/asset-history?asset_symbol=BTC&variant=1h" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests history = requests.get( "https://api.struct.to/v1/polymarket/asset-history", headers={"X-API-Key": "YOUR_API_KEY"}, params={"asset_symbol": "BTC", "variant": "1h"}, ).json()["data"] past = [ {**row, "outcome": "UP" if row["close_price"] > row["open_price"] else "DOWN"} for row in history ] ``` ## Step 2: subscribe to the live spot price The asset-prices room pushes sub-second ticks (rate: 0.005 credits per message). Use them to show where the asset is right now relative to the current window's locked `open_price`. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_asset_prices", { asset_symbols: ["BTC", "ETH"], }); ws.on("asset_price_tick", (event) => { const { symbol, price, timestamp_ms } = event.data; updateSpot(symbol, price, timestamp_ms); }); ``` ```bash cURL theme={null} > {"type":"join_room","payload":{"room_id":"polymarket_asset_prices"}} > {"type":"room_message","payload":{"room_id":"polymarket_asset_prices","message":{"action":"subscribe","asset_symbols":["BTC","ETH"]}}} ``` ```python Python theme={null} await ws.send(json.dumps({"type": "join_room", "payload": {"room_id": "polymarket_asset_prices"}})) await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_asset_prices", "message": {"action": "subscribe", "asset_symbols": ["BTC", "ETH"]}, }, })) ``` Each tick carries `symbol`, `price`, `timestamp_ms`, and `published_at`. Compare `price` against the open of the current window for the asset you're watching to render the live UP / DOWN indicator. ## Step 3: subscribe to window open and close events Window updates fire twice per resolution window per `(symbol, variant)`: an `open` event when the window starts, a `close` event when it ends. Rate: 0.025 credits per message. At least one of `asset_symbols` or `timeframes` is required. ```typescript SDK theme={null} await ws.subscribe("polymarket_asset_window_updates", { asset_symbols: ["BTC"], timeframes: ["1h"], }); ws.on("asset_price_window_update", (event) => { const { symbol, variant, start_time, end_time, open_price, close_price, update_type } = event.data; if (update_type === "open") { openWindow(symbol, variant, start_time, end_time, open_price); } else { const outcome = close_price > open_price ? "UP" : "DOWN"; resolveWindow(symbol, variant, start_time, close_price, outcome); } }); ``` ```bash cURL theme={null} > {"type":"room_message","payload":{"room_id":"polymarket_asset_window_updates","message":{"action":"subscribe","asset_symbols":["BTC"],"timeframes":["1h"]}}} ``` ```python Python theme={null} await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_asset_window_updates", "message": {"action": "subscribe", "asset_symbols": ["BTC"], "timeframes": ["1h"]}, }, })) ``` Merge into a per-window map keyed by `(symbol, variant, start_time)`. The `open` event tells you a new market just kicked off; the `close` event resolves it. ## Charting the underlying price Window rows give you the open and close only. To draw a full price chart of the underlying asset (the candlestick chart that sits behind the live UP / DOWN indicator), use `client.assets.getAssetCandlestick`. It returns OHLC bars for the asset's spot price at a TradingView-style resolution. ```typescript SDK theme={null} const { data: candles } = await client.assets.getAssetCandlestick({ asset_symbol: "BTC", resolution: "60", count_back: 500, }); const bars = candles.map((bar) => ({ time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c, })); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/asset-history/candlestick?asset_symbol=BTC&resolution=60&count_back=500" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} candles = requests.get( "https://api.struct.to/v1/polymarket/asset-history/candlestick", headers={"X-API-Key": "YOUR_API_KEY"}, params={"asset_symbol": "BTC", "resolution": "60", "count_back": 500}, ).json()["data"] ``` Each bar is `{ o, h, l, c, t }`: open, high, low, close, and the bar's start time in Unix milliseconds. | Parameter | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `asset_symbol` | One of `BTC`, `ETH`, `XRP`, `SOL`, `DOGE`, `BNB`, `HYPE` | | `resolution` | Candle interval in TradingView naming: `1S` (1 second), `1`, `5`, `15`, `30`, `60` (minutes), `240` (4h), `D` / `1D` (daily) | | `count_back` | Number of candles to return, newest first (max 2500) | | `from` / `to` | Optional Unix millisecond range to bound the window | | `pagination_key` | Cursor from a previous response to page further back | Pair the historical candles with the live [`asset_price_tick`](/websockets/rooms/asset-prices) stream: load the chart from this endpoint on mount, then extend the most recent bar as ticks arrive. ## Putting it together ```typescript theme={null} type Window = { symbol: string; variant: string; start_time: number; end_time: number; open_price: number; close_price: number | null; outcome: "UP" | "DOWN" | null; }; const windows = new Map(); const spot = new Map(); const key = (s: string, v: string, t: number) => `${s}:${v}:${t}`; const past = await client.assets.getAssetHistory({ asset_symbol: "BTC", variant: "1h" }); for (const row of past.data) { windows.set(key("BTC", "1h", row.start_time), { symbol: "BTC", variant: "1h", start_time: row.start_time, end_time: row.end_time, open_price: row.open_price, close_price: row.close_price, outcome: row.close_price > row.open_price ? "UP" : "DOWN", }); } ws.on("asset_price_tick", (event) => { spot.set(event.data.symbol, event.data.price); renderLiveIndicator(event.data.symbol); }); ws.on("asset_price_window_update", (event) => { const { symbol, variant, start_time, end_time, open_price, close_price, update_type } = event.data; const k = key(symbol, variant, start_time); if (update_type === "open") { windows.set(k, { symbol, variant, start_time, end_time, open_price, close_price: null, outcome: null }); } else { const outcome = close_price > open_price ? "UP" : "DOWN"; windows.set(k, { symbol, variant, start_time, end_time, open_price, close_price, outcome }); } renderWindows(); }); ``` The live indicator for the current window is `spot.get(symbol)` compared against `windows.get(currentKey).open_price`. ## Common combinations | Goal | Subscribe payload | | ------------------------------------------- | ----------------------------------------------------------------------------------------- | | Multi-timeframe Up/Down board for one asset | `polymarket_asset_window_updates`, `asset_symbols=["BTC"]`, `timeframes=["5m","1h","1d"]` | | All assets on one timeframe | `polymarket_asset_window_updates`, `timeframes=["1h"]` (no `asset_symbols`) | | Live spot for every tracked asset | `polymarket_asset_prices` with no `asset_symbols` | | Backfill long-window outcomes | `getAssetHistory({ asset_symbol: "ETH", variant: "1d" })` | | OHLC price chart for an asset | `getAssetCandlestick({ asset_symbol: "BTC", resolution: "60", count_back: 500 })` | ## Follow-on For threshold-based alerts on these resolutions (price crosses, individual window outcomes), see [`asset-price-tick`](/websockets/alerts/asset-price-tick) and [`asset-price-window-update`](/websockets/alerts/asset-price-window-update) under the alerts websocket. # Errors Source: https://docs.struct.to/guides/errors REST status codes, WebSocket close codes, and how Struct surfaces failures. Every Struct surface returns errors in a consistent shape. This page covers the wire format for REST and WebSocket. The TypeScript SDK exposes a typed hierarchy on top, documented in [SDK Errors](/sdk/errors). ## REST Failed REST requests return the same envelope as successful ones, with `success: false` and a human-readable `message`. The HTTP status reflects the error class. ```json theme={null} { "success": false, "data": null, "message": "Invalid API key" } ``` ### Status codes | Status | Meaning | Common causes | | --------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- | | `400 Bad Request` | Invalid parameters. | Missing required query string, malformed `condition_id`, unsupported candle resolution. | | `401 Unauthorized` | Missing or invalid credentials. | `X-API-Key` header absent, key disabled, or JWT signature invalid. | | `403 Forbidden` | Auth succeeded but the key is over its limits. | Org out of credits, key revoked, or quota exceeded. | | `404 Not Found` | Resource does not exist. | Market, event, or trader not indexed (yet) or wrong identifier. | | `429 Too Many Requests` | Rate limit exceeded. | See [Rate Limits](/guides/rate-limits). | | `500 Internal Server Error` | Unexpected server failure. | Transient. Retry with backoff. | | `502`, `503`, `504` | Upstream or timeout. | Transient. Retry with backoff. | ### Headers On `429`, the response includes `Retry-After` (seconds) when applicable. Honour it before retrying. ### Retrying Any `5xx`, `429`, network failure, or timeout is retryable. Use exponential backoff with jitter; never tight-loop. The TypeScript SDK does this automatically when [`retry`](/sdk/configuration) is configured. ## WebSocket ### In-band errors Invalid messages on either the rooms (`/ws`) or alerts (`/ws/alerts`) endpoint return a JSON error frame instead of closing the connection: ```json theme={null} { "error": "unknown event type" } ``` ```json theme={null} { "type": "error", "message": "filter limit exceeded" } ``` These cover protocol-level problems: unknown room IDs, malformed subscribe payloads, filter limits, and similar. The socket stays open; you can correct the message and retry. ### Close codes When the server closes the connection, it uses a standard WebSocket close code: | Code | Reason | Action | | ------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------- | | `1000` | Normal closure. | Client disconnected cleanly. No action. | | `1001` | Server going away (restart, deploy). | Reconnect with backoff. | | `1008` | Policy violation. Usually auth failure. | Fix credentials before reconnecting. Do not retry blindly. | | `1009` | Message too large. | Reduce filter size or split into multiple subscriptions. | | `1011` | Server error. | Reconnect with backoff. | | `4001` | Authentication failed. | Check `api-key` and `token` query parameters. | | `4002` | Connection limit reached. | Increase your plan or close idle sockets. See [Rate Limits](/guides/rate-limits). | | `4003` | Ping timeout (no client activity within keepalive window). | Send pings every 30s. | | `4004` | Out of credits. | Top up credits in the dashboard. | ### Reconnection When the socket drops for any non-auth reason, reconnect with exponential backoff and resubscribe to every room you were previously in. Subscriptions are not persisted server-side; they live only for the lifetime of the connection. The TypeScript SDK handles reconnect and resubscribe automatically. See [SDK WebSockets](/sdk/websockets#reconnection-and-replay). ```typescript theme={null} let attempt = 0; function connect() { const ws = new WebSocket("wss://api.struct.to/ws?api-key=YOUR_API_KEY"); ws.onopen = () => { attempt = 0; resubscribe(ws); }; ws.onclose = (event) => { if (event.code === 1008 || event.code === 4001) return; const delay = Math.min(30_000, 1_000 * 2 ** attempt); attempt += 1; setTimeout(connect, delay + Math.random() * 500); }; } ``` ## Webhooks Webhook deliveries are retried automatically when your endpoint returns a non-2xx response, with exponential backoff. Endpoints that fail repeatedly are paused; see [Webhooks](/webhooks/getting-started) for the full retry schedule. ## SDK errors The TypeScript SDK lifts these wire-level failures into a typed hierarchy: ``` StructError ├── HttpError (REST: non-2xx response) ├── NetworkError (REST: fetch failed) ├── TimeoutError (REST: request timed out) └── WebSocketError └── WebSocketClosedError ``` See [SDK Errors](/sdk/errors) for the full hierarchy, retry behaviour, and example handlers. # Fetching an entire dataset Source: https://docs.struct.to/guides/fetching-all-data Pull every market, trade, or event across all pages with cursor pagination. When you need the full set of rows behind a list endpoint (every market, every trade for an address, every event under a tag) you page through the results with the cursor, not a growing `offset`. This guide shows the pattern for a complete backfill and the things to get right when the dataset is large. ## Use the cursor, not offset List endpoints return one page plus a `pagination` block. To walk the whole dataset, feed each response's `pagination_key` into the next request until `has_more` is `false`. Reach for `pagination_key` here, never `offset`. The `offset` parameter is capped at a few thousand rows (typically the 3,000 to 5,000 range) and exists only so server-rendered pages can deep-link to a specific page. Cursor pagination has no such ceiling, so it is the only way to read a dataset end to end. See [Pagination](/sdk/pagination) for the full comparison. ## Fetching every market The SDK's `paginate` helper turns any list method into an async stream of individual items. It manages `limit` and `pagination_key` for you and stops when the server reports the last page. ```typescript SDK theme={null} import { StructClient, paginate } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const markets = []; for await (const market of paginate( (params) => client.markets.getMarkets(params), { tags: "politics" }, 200, )) { markets.push(market); } ``` ```bash cURL theme={null} key="" while :; do url="https://api.struct.to/v1/polymarket/market?tags=politics&limit=200" [ -n "$key" ] && url="$url&pagination_key=$key" resp=$(curl -s "$url" -H "X-API-Key: YOUR_API_KEY") echo "$resp" | jq '.data[]' more=$(echo "$resp" | jq -r '.pagination.has_more') key=$(echo "$resp" | jq -r '.pagination.pagination_key // empty') [ "$more" = "true" ] && [ -n "$key" ] || break done ``` ```python Python theme={null} import requests markets = [] pagination_key = None while True: params = {"tags": "politics", "limit": 200} if pagination_key: params["pagination_key"] = pagination_key resp = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, params=params, ).json() markets.extend(resp["data"]) pagination = resp.get("pagination") if not pagination or not pagination.get("has_more") or pagination.get("pagination_key") is None: break pagination_key = pagination["pagination_key"] ``` ## Fetching every trade The same pattern fetches a complete trade history. Trade endpoints cap `limit` at 250 per page, and every filter (`condition_ids`, `builder_codes`, `from`, `to`) composes with the cursor, so you can scope the backfill before you start. ```typescript theme={null} import { paginate } from "@structbuild/sdk"; for await (const trade of paginate( (params) => client.markets.getTrades(params), { condition_ids: "0xabc..." }, 250, )) { await ingest(trade); } ``` To pull every trade for a single wallet, swap in the trader endpoint: ```typescript theme={null} for await (const trade of paginate( (params) => client.trader.getTraderTrades(params), { address: "0x..." }, 250, )) { await ingest(trade); } ``` ## Any list endpoint works `paginate` accepts any list-style method bound to the client. The page size cap depends on the endpoint, so set the third argument to that endpoint's maximum for the fewest round trips. | Dataset | Method | Common filters | | ------------------- | ------------------------------- | ---------------------------------------------- | | Markets | `client.markets.getMarkets` | `tags`, `status`, `closed` | | Trades | `client.markets.getTrades` | `condition_ids`, `builder_codes`, `from`, `to` | | Events | `client.events.getEvents` | `tags`, `status` | | A trader's trades | `client.trader.getTraderTrades` | `address`, `builder_codes` | | A trader's PnL rows | `client.trader.getGlobalPnl` | `address` | ## Without the SDK If you are not on the TypeScript SDK, drive the cursor yourself. Start with no `pagination_key`, then carry forward the value from each response until the server stops returning one. ```typescript theme={null} let cursor: string | number | undefined; const all = []; while (true) { const page = await client.markets.getMarkets({ limit: 200, pagination_key: cursor, }); all.push(...page.data); if (!page.pagination?.has_more || page.pagination.pagination_key == null) break; cursor = page.pagination.pagination_key; } ``` The `pagination_key` is an opaque resumable token. Persist the last one you saw and you can restart an interrupted backfill from that point instead of starting over. ## Stopping early Exiting the loop stops fetching the next page, so you only pay for the pages you actually read. This is useful when you want the first N rows of an ordered result rather than the whole set. ```typescript theme={null} let count = 0; for await (const trade of paginate( (params) => client.markets.getTrades(params), { condition_ids: "0xabc..." }, 250, )) { if (++count >= 1000) break; await ingest(trade); } ``` ## Tips for large backfills * **Narrow before you scan.** Apply `from` / `to`, `tags`, or `condition_ids` so you fetch only the slice you need. A filtered backfill is smaller and cheaper than fetching everything and discarding rows client-side. * **Use the largest page size.** Set the page size to the endpoint cap (250 for trades) to minimise the number of round trips. * **Back off on `429`.** A tight backfill loop can hit the rate limit. Retry with exponential backoff and jitter. See [Rate Limits](/guides/rate-limits). * **Persist the cursor.** Save the last `pagination_key` so an interrupted job resumes instead of restarting. * **Don't re-fetch volatile data.** Prices, PnL, and positions change continuously. If you need them live after the initial load, subscribe to the matching websocket room instead of re-running the backfill. See [Best Practices](/guides/best-practices). # Fetching trades by builder code Source: https://docs.struct.to/guides/fetching-trades-by-builder-code Filter trades on a Polymarket builder code to surface order flow attributed to a specific app or integrator. Every CLOB v2 trade carries a `builder_code` and a `builder_fee`. The code identifies the app or integrator that routed the order, or the zero address (`0x0000...`) when the trade was placed directly through Polymarket. There is no dedicated "trades by builder" endpoint; instead, the standard trade endpoints accept a `builder_codes` filter (comma-separated, max 25) so you can scope any trade query to one or more builders, including the zero address for direct-Polymarket flow. ## When to use this * Showing a builder's recent fills inside a partner dashboard. * Comparing flow between two builder codes over a time window. * Building copy-trading or attribution views that need the originating builder for each trade. For aggregated analytics (volume, fees, retention, top traders), use the dedicated [Builders namespace](/api-reference/builders/list-builders-ranked-by-activity) instead. The guide below covers raw trade rows. ## All trades for a builder Use `GET /v1/polymarket/market/trades` with `builder_codes`. You can combine it with any other trade filter (`trade_types`, `min_usd_amount`, `from`, `to`, etc.). ```typescript SDK theme={null} const { data: trades } = await client.markets.getTrades({ builder_codes: "0xBUILDER", trade_types: "OrderFilled,OrdersMatched", limit: 100, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market/trades?builder_codes=0xBUILDER&trade_types=OrderFilled,OrdersMatched&limit=100" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/market/trades", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "builder_codes": "0xBUILDER", "trade_types": "OrderFilled,OrdersMatched", "limit": 100, }, ) trades = response.json()["data"] ``` Pass multiple codes as a comma-separated list: ``` ?builder_codes=0xBUILDER_A,0xBUILDER_B ``` ## A specific trader's trades for a builder To intersect "trades by trader X" with "routed via builder Y", use `GET /v1/polymarket/trader/trades/{address}` with the same `builder_codes` filter. ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/trades/0xTRADER?builder_codes=0xBUILDER&limit=100" \ -H "X-API-Key: YOUR_API_KEY" ``` ```typescript SDK theme={null} const { data: trades } = await client.trader.getTraderTrades({ address: "0xTRADER", builder_codes: "0xBUILDER", limit: 100, }); ``` ## Scoping to a market or time window Builder filtering composes with the rest of the trade query parameters. A few common combinations: | Goal | Parameters | | ------------------------------ | ----------------------------------------------------------------------------------- | | One builder, one market | `builder_codes=0xBUILDER&condition_ids=0xabc...` | | One builder, last 24h of fills | `builder_codes=0xBUILDER&trade_types=OrderFilled,OrdersMatched&from={now-86400000}` | | Whale flow per builder | `builder_codes=0xBUILDER&min_usd_amount=10000` | | Buys only | `builder_codes=0xBUILDER&side=0` | Timestamps on `/market/trades` and `/trader/trades` are Unix milliseconds. ## Paginating through results The `limit` cap is 250 per page. For larger backfills, the SDK's `paginate` helper streams every matching trade across pages without manual cursor handling: ```typescript theme={null} import { paginate } from "@structbuild/sdk"; for await (const trade of paginate( (params) => client.markets.getTrades(params), { builder_codes: "0xBUILDER" }, 250, )) { console.log(trade.trade_type, trade.usd_amount); } ``` See [Pagination](/sdk/pagination) for manual cursor handling and how `offset` and `pagination_key` interact. ## Streaming new trades The `polymarket_trades` websocket room accepts the same `builder_codes` filter, so you can push builder-attributed trades live instead of polling. ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "builder_codes": ["0xBUILDER"] } } } ``` See [WebSockets: trades room](/websockets/rooms/trades) for the full subscribe / unsubscribe lifecycle. # Finding market movers Source: https://docs.struct.to/guides/finding-market-movers Surface markets that just moved, gauge platform-wide momentum, and chart per-bucket activity using the markets and analytics REST endpoints. A market mover is anything that just shifted hard: a probability that jumped, platform volume that accelerated, or a bucket of activity that spiked above its neighbors. Struct exposes three REST endpoints that cover these angles. `markets.getPriceJumps` finds sharp probability moves inside one market's candle history, `analytics.getChanges` reports platform-wide percentage momentum, and `analytics.getDeltas` returns a per-bucket activity series you can render as a sparkline. ## When to use this * Movers rails and "biggest moves today" widgets. * Alerting when a market's implied probability swings past a threshold. * Dashboard headline numbers that show whether the platform is heating up or cooling down. * Sparklines that summarize recent platform activity at a glance. ## Sharp price moves in a market Use `GET /v1/polymarket/market/price-jumps` to detect candles where a market's probability moved more than a threshold. Pass either `condition_id` or `market_slug` (one is required). The `resolution` is the candle size in minutes (`"1"`, `"5"`, `"15"`, `"30"`, `"60"`, `"240"`, default `"15"`), `min_change_pct` is the minimum move to report (default `10.0`), and `lookback` is how many candles back to scan (default `1440`, max `2500`). ```typescript SDK theme={null} const { data: jumps } = await client.markets.getPriceJumps({ condition_id: "0xCONDITION", resolution: "15", min_change_pct: 10, lookback: 1440, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market/price-jumps?condition_id=0xCONDITION&resolution=15&min_change_pct=10&lookback=1440" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/market/price-jumps", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "condition_id": "0xCONDITION", "resolution": "15", "min_change_pct": 10, "lookback": 1440, }, ) jumps = response.json()["data"] ``` Each `PriceJump` row describes one move: `from` and `to` are the start and end timestamps (epoch seconds), `price_before` and `price_after` are the probabilities at each end, `change_pct` is the magnitude of the move, `direction` indicates whether it rose or fell, and `volume` and `trades_count` quantify how much trading drove it. The `condition_id` echoes the market so you can key the row in a list. ## Platform-wide momentum Use `GET /v1/polymarket/analytics/changes` for a single snapshot of how the whole platform is trending. The only parameter is `timeframe` (`"1h"`, `"24h"`, `"7d"`, `"30d"`, `"1mo"`, `"1y"`, default `"24h"`). ```typescript SDK theme={null} const { data: changes } = await client.analytics.getChanges({ timeframe: "24h", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/analytics/changes?timeframe=24h" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/analytics/changes", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "24h"}, ) changes = response.json()["data"] ``` The `AnalyticsMetricPctChange` response returns percentage changes for the headline platform metrics: `volume_usd`, `unique_traders`, `txn_count`, `fees_usd`, and related fields. Each value is the percentage move over the chosen timeframe, so positive numbers mean the platform is heating up. These map directly onto dashboard headline tiles. ## Per-bucket activity sparkline Use `GET /v1/polymarket/analytics/deltas` for a time series of platform activity you can plot as a sparkline. The `resolution` controls bucket size (`"60"`, `"240"`, `"D"`, `"W"`, `"M"`, default `"60"`), and `from`, `to`, and `count_back` bound the window. ```typescript SDK theme={null} const { data: deltas } = await client.analytics.getDeltas({ resolution: "60", count_back: 168, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/analytics/deltas?resolution=60&count_back=168" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/analytics/deltas", headers={"X-API-Key": "YOUR_API_KEY"}, params={"resolution": "60", "count_back": 168}, ) deltas = response.json()["data"] ``` Each `AnalyticsTimeBucketRow` uses compact keys to keep the series small. They expand as follows: | Key | Meaning | | ---- | -------------------------------- | | `t` | Bucket timestamp (epoch seconds) | | `v` | Volume in USD for the bucket | | `tc` | Transaction count | | `ut` | Unique traders | | `f` | Fees in USD | | `bv` | Buy volume in USD | | `sv` | Sell volume in USD | Plot `v` for an activity sparkline, or compare `bv` against `sv` to show whether buying or selling pressure dominated each bucket. ## Push-based movers The endpoints above are pull-based: you call them to find what already moved. For push-based movers that arrive as they happen, subscribe to the websocket spike alerts instead. The relevant alerts are [`price_spike`](/websockets/alerts/price-spike) for sharp probability moves, [`market_volume_spike`](/websockets/alerts/market-volume-spike) and [`event_volume_spike`](/websockets/alerts/event-volume-spike) for surges in trading volume, and [`position_volume_spike`](/websockets/alerts/position-volume-spike) for individual positions. Use REST to seed a movers list, then keep it live with the spike alerts. # Tracking holder growth in real time Source: https://docs.struct.to/guides/holder-metrics-tracking Watch holder counts for positions, markets, and events as they change with the holder metrics room, and backfill historical candles from REST. **What you'll build:** a live holder count for a market, event, or position that ticks as wallets enter and exit, with historical candles backfilled from REST. Useful for a "holders" counter on a market page or for spotting markets gaining holders fast. `polymarket_holder_metrics` streams real-time holder counts for positions, conditions (markets), and events, batched per block. It answers questions like "is this market gaining holders?" and "how concentrated is this position?" without polling. This recipe subscribes to the families you care about, tracks holder growth over time, and backfills history from REST. ## When to use this * A "holders" counter on a market or event page that ticks up as wallets enter. * Spotting momentum: an event whose holder count is climbing fast. * Showing a position's distribution: total balance and cost basis across all holders. ## The three families You subscribe with at least one id array, and only the families you filter for are delivered. Up to 500 identifiers total per subscription. | Filter | Event | Row | | --------------- | -------------------------------- | --------------------------- | | `position_ids` | `holder_metrics_position_batch` | `PositionHolderMetricsRow` | | `condition_ids` | `holder_metrics_condition_batch` | `ConditionHolderMetricsRow` | | `event_slugs` | `holder_metrics_event_batch` | `EventHolderMetricsRow` | This is the only one of the PnL v3 rooms whose filters are all optional, but it still needs at least one id array to return data. Each row carries `ts` (Unix seconds), `block`, and `holder_count`. Position rows add `total_balance` and `total_cost_basis`, plus `condition_holder_count` and `event_holder_count` so a single position subscription can also report the holder counts of its parent market and event. ## Step 1: subscribe to what you are watching Pass the ids for the surfaces on screen. A market page tracking one event and its markets passes both `event_slugs` and `condition_ids`. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_holder_metrics", { event_slugs: ["us-election-2028"], condition_ids: ["0xabc..."], }); ``` ```json Subscribe message theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_holder_metrics", "message": { "action": "subscribe", "event_slugs": ["us-election-2028"], "condition_ids": ["0xabc..."] } } } ``` Only the families whose id array you supplied are delivered, so the subscription above yields `holder_metrics_event_batch` and `holder_metrics_condition_batch` but not position batches. ## Step 2: track holder growth for a market or event Keep the latest count per id and, if you want a trend, append each tick to a small series so you can show direction. ```typescript theme={null} const latestHolders = new Map(); const eventSeries = new Map(); ws.on("holder_metrics_event_batch", (event) => { for (const row of event.data) { latestHolders.set(row.event_slug, row.holder_count); const s = eventSeries.get(row.event_slug) ?? []; s.push({ ts: row.ts, holders: row.holder_count }); eventSeries.set(row.event_slug, s); } renderHolderTrend(); }); ws.on("holder_metrics_condition_batch", (event) => { for (const row of event.data) { latestHolders.set(row.condition_id, row.holder_count); } }); ``` A rising `holder_count` between ticks is the clearest signal that a market is drawing fresh participation. ## Step 3: read a position's balance and cost basis Position rows carry the distribution figures. `total_balance` is the combined outcome-token balance across all holders and `total_cost_basis` is what they paid in aggregate, so the two together describe how much capital is committed to that position. ```typescript theme={null} ws.on("holder_metrics_position_batch", (event) => { for (const row of event.data) { renderPositionStats(row.position_id, { holders: row.holder_count, balance: row.total_balance, costBasis: row.total_cost_basis, marketHolders: row.condition_holder_count, eventHolders: row.event_holder_count, }); } }); ``` ## Backfilling historical candles from REST The room streams changes going forward. To draw a holder-growth chart with history, pull candles from the REST history methods, then attach the live stream on top by the same id. ```typescript theme={null} const { data: candles } = await client.holders.getPositionHoldersHistory({ position_id: "123456789...", }); ``` The matching methods are `getPositionHoldersHistory`, `getMarketHoldersHistory`, and `getEventHoldersHistory`, one per grain. Seed the chart from the candles, then let the stream extend it. ## Push without a socket: webhooks If you do not want to hold a socket open, the same data is available as webhooks: `position_holder_metrics`, `condition_holder_metrics`, and `event_holder_metrics`. Each requires its id array (`position_ids`, `condition_ids`, or `event_slugs` respectively) and delivers the same row as the matching stream family. Register one with a `POST /v1/webhooks` body specifying `url`, `event`, and `filters`. Use webhooks for server-side counters and the stream for live UIs. ## Follow-on * Full payload schemas live on the [Holder metrics room](/websockets/rooms/holder-metrics) page. * To pair holder growth with the trades driving it, see [Building a live trending feed](/guides/real-time-trending-events-and-markets). # Building a live trader PnL dashboard Source: https://docs.struct.to/guides/live-trader-pnl-dashboard Stream realized PnL for a set of wallets from the trader PnL room across three grains and four rolling windows, and keep a mark-to-market view warm with price ticks. **What you'll build:** a dashboard that shows a wallet's profit and loss live and refreshes the moment a trade lands or outcome prices move. Reach for this when you want PnL summary cards, a leaderboard, or a running "since you opened the page" number that stays current without polling. `polymarket_trader_pnl` pushes realized PnL for a set of traders as it changes on chain. PnL is computed at three grains (`global` for the whole portfolio, `market` for a single condition, `category` for a market category) and each grain is maintained over four rolling windows: `1d`, `7d`, `30d`, and `lifetime`. This recipe wires those streams into a dashboard that shows summary cards per grain and a live mark-to-market number that moves between trades. ## When to use this * Wallet dashboards with global, per-market, and per-category PnL cards. * Leaderboards that re-rank traders as their realized PnL updates. * A "since you opened the page" running PnL that tracks price moves, not just trades. ## The three families Each grain emits updates in three disjoint families. A given dirty row lands in exactly one of them per block, so you never double-count. | Family | Events | Carries `timeframe`? | Fires on | | -------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | ------------------------------------------------------ | | Full-row batch | `trader_global_pnl_batch`, `trader_market_pnl_batch`, `trader_category_pnl_batch` | Yes | A trade landed or a rolling window boundary crossed. | | Price tick | `trader_global_tick_batch`, `trader_market_tick_batch`, `trader_category_tick_batch` | No (window-agnostic) | Outcome prices moved (mark-to-market refresh). | | Resolution | `trader_global_resolution_batch`, `trader_market_resolution_batch`, `trader_category_resolution_batch` | No (window-agnostic) | The owning market resolved with no accompanying trade. | Full-row batches are the heavy, complete snapshots: a global row carries win rate, profit factor, volume breakdowns, best and worst trade, and the realized PnL for one specific window. The server sends one envelope per grain per family per block, and for full-row batches one per window as well, so a `trader_global_pnl_batch` envelope has a top-level `timeframe` telling you which window its rows describe. Ticks and resolutions are deliberately window-agnostic. They are light updates that say "the live numbers changed" without re-sending every window. Because they carry boundary snapshots, any per-window delta is recoverable on the client, which is why you can keep four windows warm from a single tick stream rather than four full-row streams. ## Step 1: subscribe `traders` is the only required filter. Pass the wallets your dashboard is tracking. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trader_pnl", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], }); ``` ```json Subscribe message theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_pnl", "message": { "action": "subscribe", "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"] } } } ``` The subscribe response echoes the accepted `traders`, `update_types`, and `timeframes`, plus a `rejected` array for any wallet addresses that failed validation. Check `rejected` before assuming every requested wallet is live. ## Step 2: handle full-row batches keyed by timeframe Each full-row envelope describes one window. Key your store by `(grain, timeframe, trader[, condition_id | category])` so the four windows of a wallet's global PnL live side by side and a window switcher in the UI is a lookup, not a refetch. ```typescript theme={null} type GlobalKey = `${string}:${string}`; const globalPnl = new Map(); ws.on("trader_global_pnl_batch", (event) => { const timeframe = event.timeframe; for (const row of event.data) { globalPnl.set(`${row.trader}:${timeframe}`, row); } renderGlobalCards(); }); ws.on("trader_market_pnl_batch", (event) => { for (const row of event.data) { upsertMarketCard(row.trader, event.timeframe, row.condition_id, row); } }); ws.on("trader_category_pnl_batch", (event) => { for (const row of event.data) { upsertCategoryCard(row.trader, event.timeframe, row.category, row); } }); ``` A global row gives you everything a summary card needs: `realized_pnl_usd`, `market_win_rate_pct`, `profit_factor`, `total_volume_usd`, and `best_trade_pnl_usd` / `worst_trade_pnl_usd` with their condition ids. The `first_trade_at` and `last_trade_at` fields here are Unix seconds. ## Step 3: keep a live mark-to-market number from ticks Full-row batches fire on trades and window boundaries. Between those, prices still move, and the tick family is how you reflect that. A `trader_global_tick_batch` row carries `realized_pnl_usd` and `open_positions_value`, so the trader's live equity is the sum of the two. ```typescript theme={null} const liveEquity = new Map(); ws.on("trader_global_tick_batch", (event) => { for (const row of event.data) { liveEquity.set(row.trader, row.realized_pnl_usd + row.open_positions_value); } renderLiveEquity(); }); ``` Because ticks are window-agnostic, the same tick updates the mark-to-market figure regardless of which window the card is showing. Realized PnL only changes on a trade or resolution, so when you need a per-window realized number, read it from the full-row store; use ticks for the unrealized component that moves continuously. ## Step 4: fold in resolutions When a market resolves with no accompanying trade, the change arrives as a resolution batch rather than a full-row batch. A market-grain resolution row carries `resolved`, `won`, and the updated `realized_pnl_usd`; the global resolution row updates `markets_won`, `markets_lost`, and `markets_resolved`. Apply these to the same store so win counts and realized PnL stay correct without waiting for the next full-row flush. ```typescript theme={null} ws.on("trader_global_resolution_batch", (event) => { for (const row of event.data) { bumpResolvedCounts(row.trader, row); } }); ws.on("trader_market_resolution_batch", (event) => { for (const row of event.data) { if (row.resolved) markRedeemed(row.trader, row.condition_id, row.won, row.realized_pnl_usd); } }); ``` ## Reading dirty\_kinds Every row carries a `dirty_kinds` array naming what triggered it: `trade`, `price`, `window`, or `market_resolved`. The family already tells you the broad reason (ticks are always `price`, resolutions are always `market_resolved`), but on full-row batches `dirty_kinds` distinguishes a fresh trade from a pure window roll. Use it to decide what to animate: highlight a card on `trade`, refresh quietly on `window`. ## Cutting message volume and cost The room bills per message, so subscribe to only what the dashboard renders. Three filters narrow the stream: | Filter | Effect | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `update_types` | Subset of `["global","market","category"]`. A global-only summary card should pass `["global"]` and skip the per-market firehose entirely. | | `timeframes` | Subset of `["1d","7d","30d","lifetime"]`. A dashboard that only shows lifetime numbers passes `["lifetime"]`. Ticks and resolutions ignore this filter because they are window-agnostic. | | `dirty_kinds` | Subset of `["trade","price","window","market_resolved"]` or `["all"]`. Drop `price` if you do not render a live mark-to-market number and you cut the tick stream at the source. | ```typescript theme={null} await ws.subscribe("polymarket_trader_pnl", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], update_types: ["global"], timeframes: ["lifetime"], dirty_kinds: ["trade", "market_resolved"], }); ``` A row is delivered only if its own `dirty_kinds` intersects the filter, so the example above yields lifetime global rows on trades and resolutions while suppressing the price-tick traffic. An unknown value in any of the three filters rejects the whole subscription with an error, so validate inputs before sending. ## Seeding from REST The room streams changes, not a starting snapshot. To paint the dashboard before the first block arrives, pull an initial picture from the REST `/pnl` endpoints (for example a trader outcome PnL call for the per-market cards) and merge the live rows on top by the same keys. See [Showing a trader's open and closed positions](/guides/trader-open-and-closed-positions) for the REST-seed-then-stream pattern. ## Follow-on * To track individual positions rather than aggregate PnL, pair this with [Tracking positions in real time](/guides/real-time-positions-tracking). * To plot realized PnL over time with exit annotations, see [Charting PnL with exit markers](/guides/pnl-chart-with-exit-markers). * Full payload schemas live on the [Trader PnL room](/websockets/rooms/trader-pnl) page. # Price alerts for your mobile app Source: https://docs.struct.to/guides/mobile-price-alerts Send a push notification when a market crosses a price, using the price_threshold webhook. This guide walks through letting a user set a price alert on a market in your mobile app and pushing them a notification the moment the market crosses that price. It is built on the [`price_threshold`](/webhooks/price-threshold) webhook, with [fire-and-delete](/webhooks/fire-and-delete) so each alert fires once and cleans itself up. ## How it fits together 1. A user taps "Notify me at 75%" on a market in your app. 2. Your backend creates a `price_threshold` webhook that points at your server, scoped to that market, with `one_shot: true`. 3. When the market's price crosses the target, Struct delivers the event to your server. 4. Your server looks up the device that registered the alert and sends a push notification. 5. Because the webhook is `one_shot`, it deletes itself after that single delivery. To repeat the alert, create a new one. You will need a Struct API key, a server to receive the webhook, and a push provider for your app (this guide uses [Expo](https://docs.expo.dev/push-notifications/overview/), but FCM and APNs work the same way). ## 1. Create the alert when the user opts in When the user sets an alert, create a `price_threshold` webhook from your backend and store the returned `id` against that user and device. Use `min_price` for an "above" alert (fire when the price crosses up to the target) or `max_price` for a "below" alert. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const struct = new StructClient({ apiKey: process.env.STRUCT_API_KEY! }); async function createPriceAlert(opts: { conditionId: string; target: number; // 0.0 - 1.0 direction: "above" | "below"; userId: string; deviceToken: string; }) { const { data: webhook } = await struct.webhooks.create({ url: "https://api.yourapp.com/webhooks/struct", event: "price_threshold", secret: process.env.STRUCT_WEBHOOK_SECRET!, filters: { condition_ids: [opts.conditionId], ...(opts.direction === "above" ? { min_price: opts.target } : { max_price: opts.target }), one_shot: true, }, }); // Map the webhook back to the device so the handler knows who to notify. await db.priceAlerts.insert({ webhookId: webhook.id, userId: opts.userId, deviceToken: opts.deviceToken, conditionId: opts.conditionId, target: opts.target, direction: opts.direction, }); return webhook.id; } ``` ```bash cURL theme={null} curl -X POST https://api.struct.to/v1/webhooks \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.yourapp.com/webhooks/struct", "event": "price_threshold", "secret": "your_signing_secret", "filters": { "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_price": 0.75, "one_shot": true } }' ``` `price_threshold` requires `condition_ids` or `position_ids` when `one_shot` is set, which the per-market scope here already provides. ## 2. Receive the event and send the push Struct delivers the `price_threshold` event to your endpoint as an HTTP POST. Verify the signature, look up the device that registered the alert, and forward a push notification. ```typescript Node (Express) theme={null} import express from "express"; import type { WebhookEvent } from "@structbuild/sdk"; const app = express(); app.post( "/webhooks/struct", express.raw({ type: "application/json" }), async (req, res) => { // Verify X-Webhook-Signature before trusting the body. // Full check: /webhooks/signature-verification if (!verifyStructSignature(req)) return res.status(401).end(); const delivery: WebhookEvent = JSON.parse(req.body.toString("utf8")); if (delivery.event !== "price_threshold") return res.status(200).end(); const { data } = delivery; const alert = await db.priceAlerts.findByWebhookId(delivery.webhook_id); if (!alert) return res.status(200).end(); await sendExpoPush(alert.deviceToken, { title: data.direction === "up" ? "Price target hit" : "Price dropped", body: `${data.question ?? "A market"} crossed ${Math.round( data.threshold * 100, )}% (now ${Math.round(data.price * 100)}%)`, data: { conditionId: data.condition_id, marketSlug: data.market_slug }, }); // one_shot already removed the subscription on Struct's side; drop our row too. await db.priceAlerts.deleteByWebhookId(delivery.webhook_id); res.status(200).end(); }, ); async function sendExpoPush( token: string, payload: { title: string; body: string; data?: Record }, ) { await fetch("https://exp.host/--/api/v2/push/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: token, sound: "default", ...payload }), }); } ``` Always verify the `X-Webhook-Signature` header before acting on a delivery. The full HMAC check is on the [Signature Verification](/webhooks/signature-verification) page. Respond with a `2xx` within 10 seconds so the delivery is not retried. ## 3. Let users cancel an alert If a user removes a pending alert before it fires, delete the webhook so it never delivers: ```typescript theme={null} await struct.webhooks.deleteWebhook({ webhookId: alert.webhookId }); await db.priceAlerts.deleteByWebhookId(alert.webhookId); ``` A `one_shot` alert that has already fired is gone on Struct's side, so there is nothing to cancel once the push has been sent. ## Recurring alerts `one_shot` makes each alert fire once. For an alert that should keep notifying every time a market crosses a level (a "watchlist" toggle rather than a one-time target), omit `one_shot`: the subscription stays active and re-arms after each crossing. Delete it when the user turns the watch off. ## Reading the payload The fields you will use most in the notification: * `direction`: `"up"` (crossed the `min_price` target) or `"down"` (crossed the `max_price` target). * `threshold`: the target from your filter that was crossed. * `price` / `probability`: the value that triggered it, on a `0.0`–`1.0` scale. * `question` / `market_slug` / `event_slug`: for the notification copy and for deep-linking back into your app. See the full field list on the [`price_threshold` webhook page](/webhooks/price-threshold). # Charting PnL with exit markers Source: https://docs.struct.to/guides/pnl-chart-with-exit-markers Plot a trader's realized PnL over time and overlay every position close as an annotation, colored and shaped by exit reason, using the trader exit markers room. **What you'll build:** a realized-PnL chart with a marker pinned on every closed position, labelled by how the trade ended (won or lost, held to resolution or sold early). Useful for a trader performance chart or a trade journal of wins and losses. `polymarket_trader_pnl_exits` emits one marker per position close, designed to overlay exits on a PnL chart. Each marker says how a position ended (`pnl_usd`, `ts`, and a `reason`), which is exactly what an annotation layer needs. This recipe builds a realized-PnL line and pins a marker at every exit, styled by whether the trader won or lost and whether they held to resolution or sold early. ## When to use this * A trader performance chart where each closed bet is a labelled point. * A trade journal that lists wins and losses with the dollar result of each exit. * An overlay on top of a running PnL line that shows when realized PnL stepped. ## The exit reasons Every marker carries one of four reasons. They split on two axes: held to resolution versus sold early, and won versus lost. | Reason | Meaning | Suggested style | | --------------- | ----------------------------------------------------- | ----------------------- | | `resolved_win` | Held to market resolution and the verdict was a win. | Green, filled triangle. | | `resolved_loss` | Held to market resolution and the verdict was a loss. | Red, filled triangle. | | `sold_win` | Closed before resolution with positive realized PnL. | Green, hollow circle. | | `sold_loss` | Closed before resolution with negative realized PnL. | Red, hollow circle. | Using shape for the close type (triangle for resolution, circle for a sale) and color for the outcome lets a reader decode an exit at a glance. ## Step 1: subscribe with a reasons filter `traders` is required. The optional `reasons` filter trims the stream to the exits you chart; passing it cuts message volume since the room bills per message. To plot only the losses, for example, pass the two loss reasons. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trader_pnl_exits", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], reasons: ["resolved_win", "resolved_loss", "sold_win", "sold_loss"], }); ``` ```json Subscribe message theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_pnl_exits", "message": { "action": "subscribe", "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], "reasons": ["resolved_win", "resolved_loss", "sold_win", "sold_loss"] } } } ``` Omit `reasons` (or pass `["all"]`) to receive every exit. ## Step 2: map markers to annotations Each `trader_exit_marker_batch` envelope carries an array of marker rows. A marker has `pnl_usd` (realized at exit), `ts` (Unix seconds), `reason`, and the descriptive fields (`title`, `outcome`, `market_slug`) for a tooltip. Map each one to your chart's annotation shape. ```typescript theme={null} type Annotation = { x: number; y: number; color: "green" | "red"; shape: "triangle" | "circle"; label: string; }; const STYLE: Record> = { resolved_win: { color: "green", shape: "triangle" }, resolved_loss: { color: "red", shape: "triangle" }, sold_win: { color: "green", shape: "circle" }, sold_loss: { color: "red", shape: "circle" }, }; const annotations: Annotation[] = []; ws.on("trader_exit_marker_batch", (event) => { for (const marker of event.data) { const style = STYLE[marker.reason]; annotations.push({ x: marker.ts * 1000, y: marker.pnl_usd, ...style, label: `${marker.title}: ${marker.pnl_usd >= 0 ? "+" : ""}${marker.pnl_usd.toFixed(2)}`, }); } redrawAnnotations(annotations); }); ``` The marker `ts` is in seconds; most charting libraries expect milliseconds, so multiply by 1000 when placing the point on a time axis. ## Step 3: draw the underlying PnL line Markers are the overlay; the line beneath them is the trader's realized PnL over time. Drive it from the trader PnL tick stream so the curve moves continuously and each marker lands on the line at the moment of the exit. Subscribe to `polymarket_trader_pnl` for the same wallet and append the global realized figure as it ticks. ```typescript theme={null} const series: { x: number; y: number }[] = []; await ws.subscribe("polymarket_trader_pnl", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], update_types: ["global"], }); ws.on("trader_global_tick_batch", (event) => { for (const row of event.data) { series.push({ x: Date.now(), y: row.realized_pnl_usd }); } redrawLine(series); }); ``` Realized PnL steps at each exit, so a marker drawn at `(ts, pnl_usd)` sits on the riser of the step it caused. See [Building a live trader PnL dashboard](/guides/live-trader-pnl-dashboard) for the full PnL stream model, including the difference between full-row, tick, and resolution families. ## Backfilling the chart The room streams exits going forward. To paint historical markers when the chart first loads, seed from your own records or a REST PnL pull, then attach the live stream for new exits on top, the same seed-then-stream pattern used across these guides. ## Follow-on * For the realized and unrealized numbers behind the line, see [Building a live trader PnL dashboard](/guides/live-trader-pnl-dashboard). * To track the positions that produce these exits, see [Tracking positions in real time](/guides/real-time-positions-tracking). * Full payload schemas live on the [Trader exit markers room](/websockets/rooms/trader-exit-markers) page. # Rate Limits Source: https://docs.struct.to/guides/rate-limits REST throughput, websocket connections, webhook caps, and how to stay under them. Struct enforces rate limits per API key, with caps scoped to your organisation's plan. This page covers every dimension that's limited and what to do when you hit one. For plan pricing and credit balances, see [Pricing](/introduction/pricing). ## REST throughput REST limits are enforced as both requests-per-second and requests-per-minute. The tighter of the two applies. | Plan | Requests/second | Requests/minute | | ---------- | --------------- | --------------- | | Free | 5 | 120 | | Hobby | 25 | 1,000 | | Startup | 125 | 5,000 | | Scale | 300 | 15,000 | | Enterprise | Custom | Custom | Limits are per API key, tracked with a sliding window. When you exceed a limit, the API returns `429 Too Many Requests` with a `Retry-After` header (seconds) when applicable. ```json theme={null} { "success": false, "data": null, "message": "Rate limit exceeded" } ``` ## WebSocket connections Each organisation has a cap on concurrent open websocket connections across rooms and alerts combined. | Plan | Concurrent connections | | ---------- | ---------------------- | | Free | 1 | | Hobby | 50 | | Startup | 250 | | Scale | 1,000 | | Enterprise | Unlimited | A single connection can subscribe to every room and every alert. You rarely need more than a handful of sockets in production: one per logged-in user for alerts, plus a small pool for background streams. When you exceed the cap, new connections are rejected at handshake with close code `4002`. ## Webhook subscriptions | Plan | Max active webhooks | | ---------- | ------------------- | | Free | 10 | | Hobby | 5,000 | | Startup | 25,000 | | Scale | 100,000 | | Enterprise | Unlimited | Webhook delivery itself is unmetered; you pay per delivered event in credits. See [Webhook Pricing](/webhooks/pricing). ## Filter limits Every websocket room has a per-subscription filter cap. Across the rooms WebSocket, no single client can hold more than **500 total filter items** at once (summed across all rooms on the same connection). If you need to track more than 500 entities, open additional connections (within your plan's connection cap) or move to a subscription-free room and filter client-side for hot entities only. ## Credits Every REST call and every websocket message is billed in credits. Plans include a monthly credit allowance; overages are billed per credit. * REST: typically **1 credit per call**, with exceptions documented on [Pricing](/introduction/pricing). * WebSocket: **per-message rate** that varies by room. See [WebSocket Pricing](/websockets/pricing). * Webhooks: **0.1 or 0.2 credits per delivered event**. See [Webhook Pricing](/webhooks/pricing). * Alerts WebSocket: same per-event rate as webhooks. Credits are billed integer-rounded at the end of each period. ## What to do when limited ### REST 1. Respect `Retry-After` when present. 2. Use **exponential backoff with jitter** for retries on `429`, `5xx`, network errors, and timeouts. Cap the backoff at 30 seconds. 3. Cache aggressively. Markets, events, and trader profiles change slowly and are safe to keep in your own layer for minutes at a time. 4. Prefer **batch endpoints** over fan-out (for example, the bulk wallet positions endpoint over N single-wallet calls). 5. Move polling to **websockets** when you're refreshing the same data repeatedly. ### WebSocket 1. **Filter at subscribe time, not in your code.** Server-side filtering is free; client-side filtering still bills you for the message. 2. **Pool subscriptions** on a single connection. The 1-credit connection hold is per-connect, and the same socket can hold many rooms. 3. **Reconnect with backoff**, not in a tight loop. Every connect re-incurs the 1-credit hold. 4. Drop subscriptions you no longer need with `unsubscribe_all` or `leave_room` so you stop being billed for incoming messages. See [Best Practices](/guides/best-practices) for patterns that keep you well under these limits. # Tracking positions in real time Source: https://docs.struct.to/guides/real-time-positions-tracking Seed a portfolio from REST positions, then keep every open and closed position live with the trader positions room: trades, mark-to-market price ticks, and resolutions. **What you'll build:** a live "my positions" view that stays in sync as a wallet trades, as outcome prices move, and as markets resolve. You seed it once from REST, then keep it warm over a websocket so the open and closed sections never go stale. `polymarket_trader_positions` pushes per-position updates for a set of wallets as they happen, batched per block. This recipe seeds a portfolio map from REST, then keeps it warm with three envelope types so the UI reflects new trades, moving prices, and market resolutions without polling. ## When to use this * A live "my positions" view with open and closed sections. * A portfolio value ticker that moves with outcome prices between trades. * Surfacing redeemable and mergeable flags so a user knows when they can claim or convert. ## The three envelope types | Event | Fires on | Shape | | ---------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ | | `trader_position_batch` | A trade landed (buy, sell, merge, split, redemption, NegRisk convert). | Full position rows. | | `trader_position_price_batch` | Outcome prices moved. | Compact price ticks (`dirty_kinds` is `["price"]`). | | `trader_position_resolution_batch` | The position's market resolved. | Resolution ticks (`dirty_kinds` is `["position_resolved"]`). | Full rows are the complete record of a position. Price ticks and resolution ticks are light updates that patch the live fields of a row you already hold, so you keep a single map keyed by `position_id` and merge each envelope in place. Note that timestamps in this room (`first_trade_at`, `last_trade_at`) are Unix **milliseconds**, unlike the trader PnL room where they are seconds. ## Step 1: seed from REST Pull the current position book so the portfolio is painted before the first block arrives. Use a trader outcome PnL call with `min_shares: 0` to include fully exited positions, then build a map keyed by `position_id`. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: rows } = await client.trader.getTraderOutcomePnl({ address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", min_shares: 0, timeframe: "lifetime", }); const positions = new Map(); for (const row of rows) positions.set(row.position_id, row); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/outcome-pnl/0xd8da6bf26964af9d7eed9e03e53415d37aa96045?min_shares=0&timeframe=lifetime" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests rows = requests.get( "https://api.struct.to/v1/polymarket/trader/outcome-pnl/0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers={"X-API-Key": "YOUR_API_KEY"}, params={"min_shares": 0, "timeframe": "lifetime"}, ).json()["data"] positions = {r["position_id"]: r for r in rows} ``` See [Showing a trader's open and closed positions](/guides/trader-open-and-closed-positions) for more on the REST shape and the open versus closed split. ## Step 2: subscribe and merge full rows `traders` is the only required filter. Each `trader_position_batch` carries full rows; upsert them by `position_id`. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trader_positions", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], }); ws.on("trader_position_batch", (event) => { for (const row of event.data) { positions.set(row.position_id, row); classify(row); } }); ``` ```json Subscribe message theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_positions", "message": { "action": "subscribe", "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"] } } } ``` A position is **open** when `current_shares_balance > 0` and **closed** when it reaches `0`. The full row also carries `realized_pnl_usd`, `realized_pnl_pct`, `avg_entry_price`, and the descriptive fields (`title`, `question`, `outcome`, `image_url`) you need to render a card. ## Step 3: patch the mark-to-market value from price ticks Between trades, prices move, and `trader_position_price_batch` is how the live value tracks them. Each tick carries `current_price`, `current_value`, and the refreshed `realized_pnl_usd` for one `position_id`. The unrealized value of an open position is `current_price` times `current_shares_balance`, and the tick's `current_value` is exactly that product, so you can use it directly. ```typescript theme={null} ws.on("trader_position_price_batch", (event) => { for (const tick of event.data) { const pos = positions.get(tick.position_id); if (!pos) continue; pos.current_price = tick.current_price; pos.current_value = tick.current_value; pos.realized_pnl_usd = tick.realized_pnl_usd; pos.realized_pnl_pct = tick.realized_pnl_pct; } renderPortfolioValue(); }); ``` Recompute the portfolio total as the sum of `current_value` across open positions plus realized PnL on closed ones, and the ticker moves smoothly with the market. ## Step 4: handle resolutions When a position's market resolves, `trader_position_resolution_batch` delivers the verdict: `resolved`, `won`, and the final `realized_pnl_usd`. Apply it, then re-check the redeemable flag. ```typescript theme={null} ws.on("trader_position_resolution_batch", (event) => { for (const res of event.data) { const pos = positions.get(res.position_id); if (!pos) continue; pos.open = false; pos.won = res.won ?? undefined; pos.realized_pnl_usd = res.realized_pnl_usd; classify(pos); } }); ``` ## Surfacing redeemable and mergeable flags The full row carries two action flags worth surfacing in the UI: * `redeemable` is true when the market has resolved and the wallet still holds shares, meaning there is a payout to claim. * `mergeable` is true for an unresolved NegRisk market where the wallet holds shares, meaning positions can be merged back to collateral. ```typescript theme={null} function classify(pos: TraderPositionRow) { if (pos.redeemable) flagAsRedeemable(pos); if (pos.mergeable) flagAsMergeable(pos); if ((pos.current_shares_balance ?? 0) > 0) moveToOpen(pos.position_id); else moveToClosed(pos.position_id); } ``` Both flags live on full rows, so they update on the next `trader_position_batch` after a trade or, for resolution-driven redeemability, after you apply a resolution tick and re-evaluate. ## Cutting message volume This room bills per message. The `dirty_kinds` filter (a subset of `["trade","price","position_resolved"]` or `["all"]`) lets you drop families you do not render. A portfolio view that only redraws on trades and resolutions, not on every price move, can omit `price`: ```typescript theme={null} await ws.subscribe("polymarket_trader_positions", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], dirty_kinds: ["trade", "position_resolved"], }); ``` ## Follow-on * For aggregate realized PnL summary cards alongside the position list, see [Building a live trader PnL dashboard](/guides/live-trader-pnl-dashboard). * To annotate a PnL chart with the moment each position closed, see [Charting PnL with exit markers](/guides/pnl-chart-with-exit-markers). * Full payload schemas live on the [Trader positions room](/websockets/rooms/trader-positions) page. # Building a live trending feed Source: https://docs.struct.to/guides/real-time-trending-events-and-markets Build a trending feed that seeds from the events or markets list and stays warm via the events stream and markets stream rooms. A trending feed is a list ranked by recent activity (volume, trade count, unique traders) that updates as activity changes. Struct exposes the data in two layers: REST listings to seed initial state, and the corresponding stream rooms to push deltas. Polymarket data is split into two surfaces, events (multi-market questions) and markets (single conditions), so this recipe covers both side by side. ## When to use this * Discovery surfaces: homepage rails, "what's hot now" widgets, category browsers. * Live leaderboards ranked by 24h volume, trade count, or unique traders. * Any UI where a row's metrics dictate ranking and the ranking itself shifts in real time. ## Choose your surface | Surface | When to use | REST | Stream | | ------- | ---------------------------------------------------------------------------------------------- | --------------------------- | --------------------------- | | Events | Multi-market questions (election, tournament, weekly series). Rows include nested `markets[]`. | `client.events.getEvents` | `polymarket_events_stream` | | Markets | Single condition rows (one outcome pair). Rows include `outcomes[]` and `clob_rewards[]`. | `client.markets.getMarkets` | `polymarket_markets_stream` | Both stream rooms push **full rows** (not deltas) for whichever rows changed since the last flush tick, so client merging is by primary key (`id` for events, `condition_id` for markets) and re-ranking happens on each tick. ## Step 1: seed from REST ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: events } = await client.events.getEvents({ limit: 50 }); const { data: markets } = await client.markets.getMarkets({ limit: 50 }); const trendingEvents = [...events].sort( (a, b) => (b.metrics?.["24h"]?.volume ?? 0) - (a.metrics?.["24h"]?.volume ?? 0), ); const trendingMarkets = [...markets].sort( (a, b) => (b.metrics?.["24h"]?.volume ?? 0) - (a.metrics?.["24h"]?.volume ?? 0), ); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/events?limit=50" \ -H "X-API-Key: YOUR_API_KEY" curl "https://api.struct.to/v1/polymarket/market?limit=50" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests events = requests.get( "https://api.struct.to/v1/polymarket/events", headers={"X-API-Key": "YOUR_API_KEY"}, params={"limit": 50}, ).json()["data"] markets = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "YOUR_API_KEY"}, params={"limit": 50}, ).json()["data"] trending_events = sorted(events, key=lambda e: (e.get("metrics", {}).get("24h", {}).get("volume") or 0), reverse=True) trending_markets = sorted(markets, key=lambda m: (m.get("metrics", {}).get("24h", {}).get("volume") or 0), reverse=True) ``` Both row shapes carry a `metrics` map keyed by timeframe (`1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`) with `volume`, `fees`, `txns`, and `unique_traders` per window. Pick the timeframe that matches the rail's framing and sort by it client-side. ## Step 2: subscribe to deltas Both stream rooms accept the same filters as their REST list counterparts and push every matching row that changed. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_events_stream", { interval_ms: 1000, mode: "filter", filter: { timeframe: "24h", min_volume: 10000 }, }); await ws.subscribe("polymarket_markets_stream", { interval_ms: 1000, mode: "filter", filter: { timeframe: "24h", min_volume: 50000 }, }); ws.on("events_stream_update", (event) => { for (const row of event.data) eventsById.set(row.id, row); rerankEvents(); }); ws.on("markets_stream_update", (event) => { for (const row of event.data) marketsByCondition.set(row.condition_id, row); rerankMarkets(); }); ``` ```bash cURL theme={null} > {"type":"join_room","payload":{"room_id":"polymarket_events_stream"}} > {"type":"room_message","payload":{"room_id":"polymarket_events_stream","message":{"action":"subscribe","interval_ms":1000,"mode":"filter","filter":{"timeframe":"24h","min_volume":10000}}}} > {"type":"join_room","payload":{"room_id":"polymarket_markets_stream"}} > {"type":"room_message","payload":{"room_id":"polymarket_markets_stream","message":{"action":"subscribe","interval_ms":1000,"mode":"filter","filter":{"timeframe":"24h","min_volume":50000}}}} ``` ```python Python theme={null} import json await ws.send(json.dumps({"type": "join_room", "payload": {"room_id": "polymarket_events_stream"}})) await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_events_stream", "message": { "action": "subscribe", "interval_ms": 1000, "mode": "filter", "filter": {"timeframe": "24h", "min_volume": 10000}, }, }, })) ``` See [Events Stream](/websockets/rooms/events-stream) and [Markets Stream](/websockets/rooms/markets-stream) for the full payload schemas. ## Step 3: re-rank on each tick Keep an in-memory map keyed by primary key, merge each pushed row in place, then re-sort. Quiet rows produce zero messages, so re-ranking is bounded by how many rows actually shifted. ```typescript theme={null} const eventsById = new Map(); const marketsByCondition = new Map(); function rerankEvents() { const ranked = [...eventsById.values()].sort( (a, b) => (b.metrics?.["24h"]?.volume ?? 0) - (a.metrics?.["24h"]?.volume ?? 0), ); renderTrendingEvents(ranked.slice(0, 20)); } function rerankMarkets() { const ranked = [...marketsByCondition.values()].sort( (a, b) => (b.metrics?.["24h"]?.volume ?? 0) - (a.metrics?.["24h"]?.volume ?? 0), ); renderTrendingMarkets(ranked.slice(0, 20)); } ``` ## Choosing a cadence Each stream room offers four flush slots: `interval_ms` of `500`, `1000`, `3000`, or `10000`. Pick `1000` for an interactive trending rail, `3000` for a sidebar widget, `10000` for a low-traffic dashboard. Each `(interval_ms, mode)` pair is its own slot; up to 8 slots per room. ## Common combinations | Goal | Filter | | ---------------------- | ------------------------------------------------------------------------------------------ | | Crypto trending | `categories=["crypto"], timeframe="24h", min_volume=50000` | | New and busy | `start_time={now-86400}, min_unique_traders=50` | | Search-driven trending | `search="election", timeframe="24h"` (3 to 100 chars, case-insensitive substring on title) | | Rewarded markets only | `has_rewards=true, timeframe="24h"` (markets stream only) | | Track by id | `mode="ids", event_slugs=[...]` or `mode="ids", condition_ids=[...]` | ## Follow-on If trending also needs to highlight markets paying CLOB rewards, the [Markets Stream](/websockets/rooms/markets-stream) row carries a `clob_rewards[]` array and a `total_daily_rate` summary field per row. # Tracking a trader's combo (parlay) PnL Source: https://docs.struct.to/guides/trader-combo-pnl List a wallet's multi-leg combo positions and expand one parlay into its legs, implied probability, and potential payout using the trader combo PnL endpoints. **What you'll build:** a parlay portfolio view for a Polymarket wallet — every combo the trader holds, each expanded into its legs with live prices, won/lost/pending status, and the combo-level PnL row. A **combo** (combinatorial market / parlay) is one tradeable instrument whose payout depends on multiple underlying legs. Struct treats the combo as a single position with a single entry price, so there is no separate per-leg PnL. What you get instead is the useful decomposition: the trader's combo position row, plus each leg's market metadata, live price, and result status, together with derived figures a parlay UI needs (`implied_probability`, `potential_payout`, `is_dead`, leg counts). ## When to use this * Wallet pages that separate parlays from standard binary and neg-risk positions. * Drilldowns from a combo row into "which legs are still live?" * Leaderboard or scout tools that rank wallets by `combo_trade_count` and then open their combo book. ## Combo vs combos | Endpoint | Use when | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | [`GET /v1/polymarket/trader/pnl/{address}/combos`](/api-reference/trader/list-combo-positions-with-legs-metadata-and-pnl) | List the trader's combos (one entry per parlay), with filters, sort, and pagination. | | [`GET /v1/polymarket/trader/pnl/{address}/combo`](/api-reference/trader/combo-position-with-legs-metadata-and-pnl) | Expand one combo by `condition_id` or `position_id`. | Both return the same entry shape (`ComboPnlResponse`). Listing is for the portfolio; the singular endpoint is for a detail view after the user clicks a row. ## Step 1: list the trader's combos Page through open parlays first, then widen the status filter as needed. ```bash theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/combos?status=open&sort_by=total_pnl_usd&limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python theme={null} import requests address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" resp = requests.get( f"https://api.struct.to/v1/polymarket/trader/pnl/{address}/combos", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "status": "open", "sort_by": "total_pnl_usd", "sort_direction": "desc", "limit": 10, }, ).json() combos = resp["data"] pagination = resp.get("pagination") or {} ``` ### Filters and sort | Param | Notes | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | Lifecycle filter. Omit for all. Values: `open`, `closed`, `resolved`, `resolved_win`, `resolved_loss`, `redeemable`, `redeemed`. `resolved` matches wins and losses; `resolved_win` also matches redeemable and redeemed. | | `search` | Case-insensitive substring on the combo market title. | | `sort_by` | Default `total_pnl_usd`. Also: `realized_pnl_usd`, `unrealized_pnl_usd`, fee-excluded `raw_*` PnL fields, `total_buy_usd`, `first_trade_at`, `last_trade_at`, `title`, `end_date`, `redeemable`. Aggregates are across the combo's held sides. | | `sort_direction` | Default `desc`. | | `limit` / `offset` / `pagination_key` | Page size default 10, max 100. `offset` is clamped to 3500 and takes precedence over `pagination_key`. | Each call costs **1 credit**. ## Step 2: read one combo entry Each list item (and the singular endpoint response) looks like this: | Field | Meaning | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_id` | Normalized combo condition id. | | `position` | The trader's position row for this combo (same shape as [positions](/api-reference/trader/get-trader-position-pnl)). Omitted when the wallet has no position on this combo. | | `status` | Combo lifecycle: `open`, `closed`, `resolved_win`, `resolved_loss`, `redeemable`, `redeemed`. | | `implied_probability` | Probability the held side pays out, from current leg prices (won = 1, lost = 0, pending = last price). | | `potential_payout` | Gross USD payout if the held side can still win or redeem, or the realized redemption amount when already redeemed. Omitted for dead losing positions and zero-balance closed positions. | | `is_dead` | `true` once the position can no longer pay out. | | `legs_won` / `legs_lost` / `legs_pending` / `leg_count` | Leg tallies. | | `legs` | Per-leg detail: market metadata, `last_price`, and `status` (`won` / `lost` / `pending`). | A Yes combo dies when any leg loses. A No combo dies when every leg has resolved and none lost (the all-win case). Use `is_dead` and the leg tallies to drive "still alive" badges without re-deriving the rules client-side. ## Step 3: expand a single combo Pass exactly one of `condition_id` or `position_id`. Use `position_id` when you already have a row from positions or from `/combos` (it selects that outcome side). Use `condition_id` when you only know the market; the API prefers the Yes side and falls back to whichever side the trader holds. ```bash theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/combo?condition_id=0x03..." \ -H "X-API-Key: YOUR_API_KEY" ``` ```python theme={null} detail = requests.get( f"https://api.struct.to/v1/polymarket/trader/pnl/{address}/combo", headers={"X-API-Key": "YOUR_API_KEY"}, params={"position_id": combos[0]["position"]["position_id"]}, ).json()["data"] for leg in detail["legs"]: print(leg.get("title"), leg.get("outcome"), leg.get("status"), leg.get("last_price")) ``` Unknown combos return **200** with `data: null` and an `unknown combo` message (not a 404). Passing both ids, neither id, a non-combo id, or a malformed id returns **400**. Each call costs **1 credit**. ## Finding combos from the rest of the PnL suite You do not have to start on `/combos`. Related fields on existing trader PnL surfaces: * **Positions / markets:** rows carry `combo_type: "combinatorial"` when the market is a combo (parlay). Filter with `combo=combinatorial` (or `true` / `false`) on [positions](/api-reference/trader/get-trader-position-pnl) and [markets](/api-reference/trader/get-trader-market-pnl). * **Trader summary / global leaderboard:** `combo_trade_count` is the number of combo-exchange fills. Sort the [global PnL leaderboard](/api-reference/trader/get-global-pnl-leaderboard) with `sort_by=combo_trade_count` to find active parlay traders, then open `/combos` for that wallet. ```bash theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/positions?status=open&combo=combinatorial" \ -H "X-API-Key: YOUR_API_KEY" ``` From a position row, pass its `position_id` into `/combo` for the leg breakdown. ## Related * API reference: [List combo PnL](/api-reference/trader/list-combo-positions-with-legs-metadata-and-pnl), [Single combo PnL](/api-reference/trader/combo-position-with-legs-metadata-and-pnl) * [Trader open and closed positions](/guides/trader-open-and-closed-positions) * [Live trader PnL dashboard](/guides/live-trader-pnl-dashboard) * Market-level Combos APIs (list, metrics, analytics, candlesticks, holders): [List combo markets](/api-reference/combos/list-combo-markets), [Global combo analytics snapshot](/api-reference/combos/global-combo-analytics-snapshot), [`GET /v1/polymarket/combos/legs`](/api-reference/combos/expand-a-combo-into-its-legs-with-market-metadata) # Showing a trader's open and closed positions Source: https://docs.struct.to/guides/trader-open-and-closed-positions Render a trader's portfolio (held vs exited) using the trader outcome PnL endpoint, then keep it live via the trader positions room. `client.trader.getTraderOutcomePnl` returns one row per outcome a wallet has ever traded, with `current_shares_balance`, `realized_pnl_usd`, and supporting fields. That single response covers both halves of a portfolio view: open positions (still held) and closed positions (fully exited). The same row shape is pushed live by `polymarket_trader_positions`, so a portfolio UI seeded from REST can stay in sync with one merge key. ## When to use this * Wallet portfolio pages with separate "Open" and "Closed" sections. * Leaderboard drilldowns where clicking a trader expands to their bets. * "My open bets" UI for an authenticated user, with realized PnL surfaced for closed exits. ## Open vs closed model A position is **open** when `current_shares_balance > 0`: the wallet still holds outcome tokens, so the row has both unrealised exposure and realized PnL from any partial sells. A position is **closed** when `current_shares_balance === 0`: the wallet has fully exited (sold or redeemed), and only `realized_pnl_usd` remains relevant. The same row carries both halves of the lifecycle, which means you do not need two endpoints. Filter once in client code. ## Step 1: fetch the position book Use `min_shares: 0` to include closed rows; bump it to drop dust positions. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: rows } = await client.trader.getTraderOutcomePnl({ address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", min_shares: 0, timeframe: "lifetime", }); const open = rows.filter((r) => (r.current_shares_balance ?? 0) > 0); const closed = rows.filter((r) => (r.current_shares_balance ?? 0) === 0); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/outcome-pnl/0xd8da6bf26964af9d7eed9e03e53415d37aa96045?min_shares=0&timeframe=lifetime" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests rows = requests.get( "https://api.struct.to/v1/polymarket/trader/outcome-pnl/0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers={"X-API-Key": "YOUR_API_KEY"}, params={"min_shares": 0, "timeframe": "lifetime"}, ).json()["data"] open_positions = [r for r in rows if (r.get("current_shares_balance") or 0) > 0] closed_positions = [r for r in rows if (r.get("current_shares_balance") or 0) == 0] ``` ## Step 2: split and sort Open positions usually rank by current mark-to-market value. Closed positions rank by realized PnL. ```typescript theme={null} const sortedOpen = [...open].sort( (a, b) => (b.current_shares_balance ?? 0) * (b.latest_price ?? 0) - (a.current_shares_balance ?? 0) * (a.latest_price ?? 0), ); const sortedClosed = [...closed].sort( (a, b) => (b.realized_pnl_usd ?? 0) - (a.realized_pnl_usd ?? 0), ); ``` ## Step 3: scope to a timeframe Pass `timeframe` to limit `realized_pnl_usd` (and any windowed counters) to a window: `1d`, `7d`, `30d`, or `lifetime`. Use this to drive period switchers on the portfolio header. ```typescript theme={null} const { data: last7d } = await client.trader.getTraderOutcomePnl({ address: "0x...", timeframe: "7d", }); ``` You can also narrow the response to a specific event or market via `event_slug` or `condition_id`, useful for "this trader's bets on the election" drilldowns. ## Step 4: keep it live Subscribe to `polymarket_trader_positions` with the same address. Every `trader_position_update` carries `(trader, position_id)` plus `current_shares_balance` and `realized_pnl_usd`, so merging into the REST-seeded map is one-to-one. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trader_positions", { traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], }); const positions = new Map(); for (const row of rows) positions.set(row.position_id, row); ws.on("trader_position_update", (event) => { const row = event.data; positions.set(row.position_id, row); if (row.current_shares_balance === 0) movePositionToClosed(row.position_id); else upsertOpenPosition(row.position_id, row); }); ``` ```bash cURL theme={null} > {"type":"room_message","payload":{"room_id":"polymarket_trader_positions","message":{"action":"subscribe","traders":["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"]}}} ``` ```python Python theme={null} await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_trader_positions", "message": {"action": "subscribe", "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"]}, }, })) ``` When `current_shares_balance` flips to 0, move that row from the Open list to the Closed list in your UI. When it ticks up from 0, the trader has re-entered an old position; move the row back. ## Common combinations | Goal | Parameters | | ---------------------------------- | ----------------------------------------------- | | Lifetime portfolio (open + closed) | `address=0x...&min_shares=0&timeframe=lifetime` | | Weekly performance | `address=0x...&timeframe=7d` | | Drop dust | `address=0x...&min_shares=10` | | Bets on one event | `address=0x...&event_slug=us-election-2028` | | Sort by realized PnL | `address=0x...&sort_by=realized_pnl_usd` | ## Pagination For traders with long histories, the SDK's `paginate` helper walks every page without manual cursor handling. ```typescript theme={null} import { paginate } from "@structbuild/sdk"; for await (const row of paginate( (params) => client.trader.getTraderOutcomePnl(params), { address: "0x...", min_shares: 0 }, )) { ingest(row); } ``` See [Pagination](/sdk/pagination) for how `offset` and `pagination_key` interact. ## Follow-on For aggregated PnL summary cards next to the position list (global, per-event, per-market roll-ups), see the [Trader PnL room](/websockets/rooms/trader-pnl). It pairs cleanly with this recipe: positions on the left, summary numbers on the right, both fed from the same wallet address. # Charting a trader's PnL history Source: https://docs.struct.to/guides/trader-pnl-history-charts Build equity curves, daily PnL heatmaps, best and worst period summaries, and drawdown views for any Polymarket wallet from the trader PnL history REST endpoints. The trader namespace exposes a family of read endpoints that return a wallet's profit and loss in chart-ready shapes: an OHLC candlestick series for an equity curve, a daily calendar for a heatmap, best and worst day/week/month extremes, and drawdown/runup risk metrics. Each endpoint is keyed by the trader's `address` and authenticated with `X-API-Key`. REST responses are wrapped, so the payload lives under `.data`. These are historical snapshots. For a live view that updates as new fills land, see the realtime companion guide [Live trader PnL dashboard](/guides/live-trader-pnl-dashboard). ## When to use this * Drawing an equity (total PnL) candlestick chart for a single wallet. * Rendering a GitHub-style daily PnL heatmap of green and red days. * Showing a trader's best and worst day, week, and month at a glance. * Plotting maximum drawdown and runup for a risk panel. * Listing the markets where a wallet made or lost the most across the platform. ## Equity curve from PnL candles `getTraderPnlCandles` returns an OHLC series of the wallet's PnL over time, suitable for a candlestick or area chart. Pass a `resolution` (`1m`, `1h`, `4h`, `1d`, or `auto`) and a `timeframe` (`1d`, `7d`, `30d`, or `lifetime`, default `lifetime`). `count_back` defaults to 500 and caps at 2500, and `fill_gaps` carries the last close across empty buckets so the line stays continuous. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: bars } = await client.trader.getTraderPnlCandles({ address: "0xTRADER", resolution: "1d", timeframe: "lifetime", count_back: 500, fill_gaps: true, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/candles?resolution=1d&timeframe=lifetime&count_back=500&fill_gaps=true" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests bars = requests.get( "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/candles", headers={"X-API-Key": "YOUR_API_KEY"}, params={ "resolution": "1d", "timeframe": "lifetime", "count_back": 500, "fill_gaps": True, }, ).json()["data"] ``` Each element is a `PnlCandlestickBar`. The total-PnL OHLC fields drive an equity candlestick, and parallel realized and unrealized series let you split the line. | Field | Description | | ------------------- | --------------------------------------------------------------- | | `t` | Bar start time, Unix seconds | | `o` `h` `l` `c` | Total PnL open, high, low, close (nullable on empty buckets) | | `ro` `rh` `rl` `rc` | Realized PnL open, high, low, close | | `uo` `uh` `ul` `uc` | Unrealized PnL open, high, low, close | | `po` `ph` `pl` `pc` | Portfolio value open, high, low, close | | `ob` `cb` `ub` `pb` | Opening basis, closing basis, unrealized basis, portfolio basis | | `nop` | Open position count in the bar | For a simple equity line, plot `c` per bar. For a candlestick, map `o`/`h`/`l`/`c` straight into the chart and treat null buckets (gaps) as carried-forward closes unless you set `fill_gaps`. ## Daily heatmap from the calendar `getTraderPnlCalendar` returns one entry per day, ideal for a contribution-style heatmap. `days` defaults to 30 and caps at 30. ```typescript SDK theme={null} const { data: calendar } = await client.trader.getTraderPnlCalendar({ address: "0xTRADER", days: 30, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/calendar?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests calendar = requests.get( "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/calendar", headers={"X-API-Key": "YOUR_API_KEY"}, params={"days": 30}, ).json()["data"] ``` Each `PnlCandleEntry` is `{ t, pnl }`: `t` is the day in Unix seconds and `pnl` is that day's PnL in USD. Color each cell on the sign and magnitude of `pnl` to get a green and red heatmap. ## Best and worst periods `getTraderPnlPeriods` returns the wallet's extreme day, week, and month for both total PnL and portfolio value. Pass a `timeframe` (`1d`, `24h`, `7d`, `30d`, or `lifetime`) to bound the window the extremes are computed over. ```typescript SDK theme={null} const { data: periods } = await client.trader.getTraderPnlPeriods({ address: "0xTRADER", timeframe: "lifetime", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/periods?timeframe=lifetime" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests periods = requests.get( "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/periods", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "lifetime"}, ).json()["data"] ``` The `PnlPeriodsResponse` carries six extremes objects: `total_pnl_day`, `total_pnl_week`, `total_pnl_month`, `portfolio_day`, `portfolio_week`, and `portfolio_month`. Each is a `PnlPeriodExtremes` with `best` and `worst`, and each of those (when present) is a metric of `{ from, to, change, change_pct }` (`from` and `to` are Unix seconds bounding the period, `change` is the PnL over it). Render `total_pnl_day.best` and `total_pnl_day.worst` as the best and worst day, and so on for week and month. ## Drawdown and runup `getTraderPnlRisk` returns risk metrics for both the total-PnL series and the portfolio series. It takes the same `timeframe` values as periods. ```typescript SDK theme={null} const { data: risk } = await client.trader.getTraderPnlRisk({ address: "0xTRADER", timeframe: "lifetime", }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/risk?timeframe=lifetime" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests risk = requests.get( "https://api.struct.to/v1/polymarket/trader/pnl/0xTRADER/risk", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "lifetime"}, ).json()["data"] ``` `PnlRiskResponse` has `total_pnl` and `portfolio`, each a `PnlRiskMetric` (either may be null when there is no history). The metric fields: | Field | Description | | ---------------------- | --------------------------------------- | | `max_drawdown` | Largest peak-to-trough decline | | `max_drawdown_pct` | The same as a percentage (nullable) | | `current_drawdown` | Decline from the most recent peak | | `current_drawdown_pct` | The same as a percentage (nullable) | | `max_runup` | Largest trough-to-peak gain | | `max_runup_pct` | The same as a percentage (nullable) | | `high` `low` `latest` | Series high, low, and most recent value | Each drawdown and runup field also has a matching `*_market` object naming the market that drove it, so you can annotate the risk panel. ## Best and worst markets `getTopTradesMarkets` ranks markets across the platform by PnL rather than scoping to one wallet. It is global, so it takes no `address`. Pass a `timeframe` (`1d`, `7d`, `30d`, or `lifetime`), plus `limit` (default 50, max 200) and `offset` for paging. ```typescript SDK theme={null} const { data: markets } = await client.trader.getTopTradesMarkets({ timeframe: "30d", limit: 50, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/trader/top_trades_markets?timeframe=30d&limit=50" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests markets = requests.get( "https://api.struct.to/v1/polymarket/trader/top_trades_markets", headers={"X-API-Key": "YOUR_API_KEY"}, params={"timeframe": "30d", "limit": 50}, ).json()["data"] ``` Each `MarketEntry` includes `condition_id`, `title`, `question`, `image_url`, `realized_pnl_usd`, `total_pnl_usd`, `total_volume_usd`, `total_fees`, and a `trader` profile, alongside trade counts and volume breakdowns. Sort or split the rows by `total_pnl_usd` to surface the best and worst markets. ## Follow-on * [Live trader PnL dashboard](/guides/live-trader-pnl-dashboard) for the realtime companion that pushes PnL updates as fills arrive. * [SDK REST reference](/sdk/rest-api) for the full trader method signatures. * The API Reference tab for the underlying operation schemas. # TradingView charts for Polymarket Source: https://docs.struct.to/guides/tradingview-charts Render a live TradingView chart of any Polymarket outcome: backfill OHLCV from the candlestick API and stream live bars from the trades room. The [TradingView Advanced Charts](https://www.tradingview.com/charting-library-docs/) library (the `charting_library` package, not the open-source Lightweight Charts) renders price by calling a **datafeed** object you implement. This guide wires Struct into that datafeed: historical bars from the candlestick API in `getBars`, and live bars from the trades WebSocket room fed into the realtime callback. | Need | Source | | ---------------------------------------- | ----------------------------------------------------------------------------------------------- | | Historical OHLCV bars for one outcome | `client.markets.getPositionCandlestick` | | Historical OHLCV bars for a whole market | `client.markets.getCandlestick` | | Live trades to build the forming bar | [`polymarket_trades`](/websockets/rooms/trades) (`trade_stream_update`) | | Pre-aggregated live OHLC (server-side) | [`polymarket_position_metrics`](/websockets/rooms/position-metrics) (`position_metrics_update`) | Each Polymarket outcome is an ERC-1155 token identified by a numeric **position ID**. That position ID is the symbol you chart. A binary market has a Yes token and a No token, each with its own price series, so you chart one outcome at a time. ## When to use this * A candlestick chart of a single market outcome (Yes/No price over time) inside your own app. * A live trading view that backfills history on mount and extends the last bar on every fill. * Replacing a polling chart with a pushed one, no reconnect bookkeeping. If you are charting the crypto spot price behind Up/Down markets instead, use [`getAssetCandlestick`](/guides/crypto-up-down-feed#charting-the-underlying-price), not the market candlestick endpoints below. ## The candlestick endpoints `getPositionCandlestick` returns OHLCV for a single outcome token; `getCandlestick` returns it for a market by condition ID. Both return the same bar shape. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: candles } = await client.markets.getPositionCandlestick({ position_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563", resolution: "60", count_back: 500, }); ``` ```bash cURL theme={null} curl "https://api.struct.to/v1/polymarket/market/position/candlestick?position_id=71321045679...&resolution=60&count_back=500" \ -H "X-API-Key: YOUR_API_KEY" ``` ```python Python theme={null} import requests candles = requests.get( "https://api.struct.to/v1/polymarket/market/position/candlestick", headers={"X-API-Key": "YOUR_API_KEY"}, params={"position_id": "71321045679...", "resolution": "60", "count_back": 500}, ).json()["data"] ``` Each bar is `{ o, h, l, c, v, t, tc }`: | Field | Description | | --------------- | ---------------------------------------------------------- | | `o` `h` `l` `c` | Open, high, low, close (each `0`–`1`, the outcome's price) | | `v` | Volume in the bar | | `t` | Bar start time, **Unix milliseconds** | | `tc` | Trade count in the bar | | Parameter | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `position_id` | Outcome token ID (`getCandlestick` takes `condition_id` instead) | | `resolution` | Candle interval, TradingView naming: `1S`, `5S`, `10S`, `30S` (seconds), `1`, `5`, `15`, `30`, `60`, `240` (minutes), `D` / `1D` (daily) | | `count_back` | Number of candles to return (max 2500) | | `from` / `to` | Optional Unix **second** range to bound the window | | `pagination_key` | Cursor from a previous response to page further back | Request bounds (`from` / `to`) are in Unix **seconds**, but each returned bar's `t` is in Unix **milliseconds**. TradingView's `Bar.time` wants milliseconds for intraday resolutions, so `t` maps straight across with no conversion. ## Step 1: getBars TradingView calls `getBars` on first load and again as the user pans into older data. `periodParams` gives the window (`from`, `to` in Unix seconds), the bar count (`countBack`), and whether this is the first request. Map the response into the library's `Bar` type and sort ascending; out-of-order bars are rejected. Page older history with the cursor, not the window. Send `from` / `to` (or just `count_back`) on the **first** request to anchor the visible range, then follow the `pagination_key` from each response on every subsequent call. The `pagination.has_more` flag — not the returned row count — tells you when the series is exhausted, so keep a small `state` object between calls to carry the cursor and the more-data flag. ```typescript theme={null} import type { Bar, ResolutionString, PeriodParams, HistoryCallback, DatafeedErrorCallback, } from "../charting_library"; import type { StructClient } from "@structbuild/sdk"; type ApiResolution = | "1S" | "5S" | "10S" | "30S" | "1" | "5" | "15" | "30" | "60" | "240" | "1D"; const toApiResolution = (r: ResolutionString): ApiResolution => { if (r === "D" || r === "1D") return "1D"; if (r.endsWith("S")) { const seconds = parseInt(r, 10); if (seconds <= 1) return "1S"; if (seconds <= 5) return "5S"; if (seconds <= 10) return "10S"; return "30S"; } const val = parseInt(r, 10); if (Number.isNaN(val)) return "60"; if (val <= 1) return "1"; if (val <= 5) return "5"; if (val <= 15) return "15"; if (val <= 30) return "30"; if (val <= 60) return "60"; return "240"; }; type GetBarsState = { hasMore: boolean; paginationKey: string | number | null; }; async function getBars( client: StructClient, positionId: string, resolution: ResolutionString, periodParams: PeriodParams, onResult: HistoryCallback, onError: DatafeedErrorCallback, state: GetBarsState, ) { try { const { from, to, countBack, firstDataRequest } = periodParams; if (firstDataRequest) { state.hasMore = true; state.paginationKey = null; } if (!state.hasMore && !firstDataRequest) { onResult([], { noData: true }); return; } const { data, pagination } = await client.markets.getPositionCandlestick({ position_id: positionId, resolution: toApiResolution(resolution), count_back: countBack, // First page anchors the window; older pages follow the cursor. ...(state.paginationKey != null ? { pagination_key: String(state.paginationKey) } : { from, to }), }); if (!data || data.length === 0) { state.hasMore = false; onResult([], { noData: true }); return; } const bars: Bar[] = data .filter((c) => c.o != null && c.c != null) .map((c) => ({ time: c.t, open: c.o ?? 0, high: c.h ?? 0, low: c.l ?? 0, close: c.c ?? 0, volume: c.v ?? 0, })) .sort((a, b) => a.time - b.time); // Drive pagination from the cursor, not the returned row count. state.hasMore = pagination?.has_more ?? false; state.paginationKey = state.hasMore ? pagination?.pagination_key ?? null : null; onResult(bars, { noData: bars.length === 0 }); } catch (err) { onError(err instanceof Error ? err.message : "Failed to fetch candlesticks"); } } ``` ## Step 2: live bars from the trades room `subscribeBars` should only register the chart's realtime callback. Feed updates from a separate `handleRealtimeTrade` method driven by the [`polymarket_trades`](/websockets/rooms/trades) room, so one socket serves the chart, a trade tape, and anything else. Each `trade_stream_update` carries `price` (`0`–`1`), `shares_amount`, `side`, and `confirmed_at` (Unix **seconds**). Bucket each trade into its bar, opening a new bar from the previous bar's close so the series stays continuous. ```typescript theme={null} import type { SubscribeBarsCallback } from "../charting_library"; import type { TradeStreamEvent } from "@structbuild/sdk"; const resolutionToMs = (r: ResolutionString): number => { if (r === "D" || r === "1D") return 24 * 60 * 60 * 1000; if (r.endsWith("S")) { const seconds = parseInt(r, 10); return Number.isNaN(seconds) ? 1000 : seconds * 1000; } const val = parseInt(r, 10); return Number.isNaN(val) ? 60 * 60 * 1000 : val * 60 * 1000; }; type BarSubscription = { resolution: ResolutionString; onTick: SubscribeBarsCallback; lastBar: Bar | null; }; const subscriptions = new Map(); function handleRealtimeTrade(positionId: string, trade: TradeStreamEvent) { if (trade.position_id !== positionId) return; if (trade.price == null || trade.confirmed_at == null) return; const price = trade.price; const size = trade.shares_amount ?? 0; const tsMs = trade.confirmed_at * 1000; for (const sub of subscriptions.values()) { const barMs = resolutionToMs(sub.resolution); const barTime = Math.floor(tsMs / barMs) * barMs; let bar: Bar; if (sub.lastBar && sub.lastBar.time === barTime) { bar = { ...sub.lastBar, high: Math.max(sub.lastBar.high, price), low: Math.min(sub.lastBar.low, price), close: price, volume: (sub.lastBar.volume ?? 0) + size, }; } else if (sub.lastBar && barTime > sub.lastBar.time) { bar = { time: barTime, open: sub.lastBar.close, high: Math.max(sub.lastBar.close, price), low: Math.min(sub.lastBar.close, price), close: price, volume: size, }; } else { bar = { time: barTime, open: price, high: price, low: price, close: price, volume: size }; } sub.lastBar = bar; sub.onTick(bar); } } ``` The three branches cover every case: extend the current bar, roll into a new one (open at the last close), or start cold. TradingView merges realtime bars by `time`, so the first trade after load lands on the same timestamp as the last historical bar and updates it in place. By default the trades room sends confirmed on-chain fills. Pass `status: "all"` to also receive mempool trades, which arrive before confirmation and carry `received_at` (Unix milliseconds) instead of `confirmed_at`. Use them for a faster visual tick and reconcile against confirmed data. ## Step 3: assemble the datafeed and mount `onReady` advertises supported resolutions. `resolveSymbol` describes the instrument: prices are `0`–`1`, so `pricescale: 10000` gives four decimals. Then connect the socket, subscribe to the trades room, and pipe each event into `handleRealtimeTrade`. ```typescript theme={null} import type { IBasicDataFeed, LibrarySymbolInfo, OnReadyCallback, ResolveCallback } from "../charting_library"; import { StructClient, StructWebSocket } from "@structbuild/sdk"; const SUPPORTED_RESOLUTIONS = ["1S", "5S", "10S", "30S", "1", "5", "15", "30", "60", "240", "D"] as ResolutionString[]; const SECONDS_MULTIPLIERS = ["1", "5", "10", "30"]; export function createPredictionDatafeed(positionId: string, client: StructClient) { const state: GetBarsState = { hasMore: true, paginationKey: null }; const datafeed: IBasicDataFeed = { onReady: (cb: OnReadyCallback) => { setTimeout(() => cb({ supported_resolutions: SUPPORTED_RESOLUTIONS, seconds_multipliers: SECONDS_MULTIPLIERS }), 0); }, searchSymbols: (_input, _exchange, _type, onResult) => onResult([]), resolveSymbol: (_name, onResolve: ResolveCallback) => { setTimeout(() => { onResolve({ ticker: positionId, name: "Polymarket outcome", description: "Polymarket outcome", type: "index", session: "24x7", timezone: "Etc/UTC", exchange: "Polymarket", listed_exchange: "Polymarket", format: "price", minmov: 1, pricescale: 10000, has_intraday: true, has_seconds: true, seconds_multipliers: SECONDS_MULTIPLIERS, has_daily: true, has_weekly_and_monthly: false, supported_resolutions: SUPPORTED_RESOLUTIONS, volume_precision: 2, data_status: "streaming", } as LibrarySymbolInfo); }, 0); }, getBars: (_symbolInfo, resolution, periodParams, onResult, onError) => getBars(client, positionId, resolution, periodParams, onResult, onError, state), subscribeBars: (_symbolInfo, resolution, onTick, uid) => { subscriptions.set(uid, { resolution, onTick, lastBar: null }); }, unsubscribeBars: (uid) => subscriptions.delete(uid), }; return datafeed; } ``` ```typescript theme={null} const client = new StructClient({ apiKey: "pk_jwt_xxx", jwt: userJwt }); const ws = new StructWebSocket({ apiKey: "pk_jwt_xxx", getJwt: () => userJwt }); const datafeed = createPredictionDatafeed(positionId, client); await ws.connect(); await ws.subscribe("polymarket_trades", { position_ids: [positionId] }); const offTrade = ws.on("trade_stream_update", (trade) => handleRealtimeTrade(positionId, trade)); const widget = new window.TradingView.widget({ symbol: positionId, interval: "60" as ResolutionString, container: "tv_chart_container", library_path: "/charting_library/", datafeed, locale: "en", autosize: true, }); // teardown // offTrade(); ws.unsubscribe("polymarket_trades"); widget.remove(); ``` For a browser chart, authenticate with a `pk_jwt_` public key plus the signed-in user's JWT, as above. The `pk_jwt_` key is safe in a frontend bundle because it is useless without a valid JWT from your configured auth provider. On a server, use your `sk_` secret key and drop the `jwt`. See [JWT auth](/sdk/authentication). ## Common combinations | Goal | Call | | ------------------------ | -------------------------------------------------------------------------------- | | Backfill one outcome | `getPositionCandlestick({ position_id, resolution: "60", count_back: 500 })` | | Backfill a whole market | `getCandlestick({ condition_id, resolution: "60" })` | | Live bars from trades | `subscribe("polymarket_trades", { position_ids: [id] })` → `trade_stream_update` | | Live confirmed + pending | `subscribe("polymarket_trades", { position_ids: [id], status: "all" })` | ## Follow-on * [Trades room](/websockets/rooms/trades) for every trade filter and the full `trade_stream_update` payload. * [Position metrics room](/websockets/rooms/position-metrics) for live probability and position-level metrics. * [SDK REST reference](/sdk/rest-api) for the candlestick method signatures. * [Crypto Up/Down feed](/guides/crypto-up-down-feed) to chart the crypto spot price behind Up/Down markets. # Getting Started Source: https://docs.struct.to/indexing/getting-started Backfill historical Polymarket data and set up realtime streams to keep your database in sync. ## Overview The Indexing & Backfilling service lets you sync Polymarket data directly into your own infrastructure. Whether you need to backfill historical data or stream updates in realtime, Struct handles the heavy lifting so you can focus on building. Bulk-load historical markets, trades, positions, and events into your database with a single configuration. Subscribe to Kafka topics for live updates, including new markets, price changes, trades, and settlements as they happen. Keep a fully mirrored copy of Polymarket data in your own Postgres, ClickHouse, or other supported databases. Struct manages the indexing pipeline end-to-end, including schema migrations, data validation, and delivery guarantees. ## Key Capabilities * **Kafka Streams**: Connect to managed Kafka topics for low-latency, ordered event delivery. Consume market updates, trades, and order book changes as they occur. * **Historical Backfilling**: Load the full history of Polymarket data into your database. Backfills run incrementally so you can restart without re-processing. * **Database Sync**: Struct writes directly to your database, handling schema creation, upserts, and deduplication automatically. * **Custom Filters**: Scope your data pipeline to specific markets, tags, or event types to reduce noise and storage costs. ## Getting Started The Indexing & Backfilling service is currently set up on a per-customer basis. To get started, reach out to our team and we'll work with you to configure a pipeline tailored to your needs. Get in touch at [support@struct.to](mailto:support@struct.to) to discuss your data requirements and infrastructure setup. We'll work with you to determine which data types (markets, trades, events, etc.) and filters match your use case. Struct provisions and configures the indexing pipeline, including your database destination, Kafka topics, and backfill schedule. Once the backfill completes, realtime streaming begins automatically to keep your data up to date. # Work with AI Source: https://docs.struct.to/introduction/ai-first Use Struct with AI agents, coding assistants, and autonomous workflows. Struct is designed to work natively with AI tools. Whether you're building autonomous agents, using an AI coding assistant, or integrating prediction market data into an AI workflow, there's a first-class path for you. Type-safe client with full autocompletion. Connect AI assistants directly to Struct data. Install via skills.sh for instant access in your agent. ## TypeScript SDK The Struct SDK gives you a fully typed client for all Polymarket endpoints. Install it and start querying in seconds. ```bash theme={null} npm install @structbuild/sdk ``` ```typescript theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "YOUR_API_KEY" }); const markets = await client.markets.getMarkets(); const event = await client.events.getEventBySlug({ slug: "us-presidential-election" }); ``` Every method returns fully typed responses, with no manual type definitions and no guessing field names. Full documentation, examples, and source code. ## MCP Server Struct exposes a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI assistants query prediction market data directly. The MCP server is available at `api.struct.to/mcp` and supports two authentication methods. ### OAuth (for AI assistants) For AI assistants like ChatGPT and Claude that support OAuth-based MCP connections, simply connect using the MCP URL: ``` https://api.struct.to/mcp ``` The assistant will handle the OAuth flow automatically, so no API key management is needed. ### API Key (for backend agents) For backend AI agents and programmatic access, connect to the MCP server directly with your API key: ```json theme={null} { "mcpServers": { "struct": { "url": "https://api.struct.to/mcp?api_key=YOUR_API_KEY" } } } ``` Once connected, your AI agent can search markets, pull real-time prices, look up trader positions, and more, all through natural language. ## AI Skill Install the Struct skill to give any Claude Code agent instant access to Struct's Polymarket data. ```bash theme={null} npx skills add structbuild/struct-skill ``` Once installed, the agent can query markets, events, trades, and positions without any additional setup. ## Choose Your Path | Approach | Best For | | ------------------------ | ----------------------------------------------- | | **REST API** | Full control, any language, custom integrations | | **TypeScript SDK** | TypeScript/JavaScript projects with type safety | | **MCP Server (OAuth)** | AI assistants like ChatGPT and Claude | | **MCP Server (API Key)** | Backend AI agents and programmatic access | | **AI Skill** | Claude Code agents via skills.sh | # Authentication Source: https://docs.struct.to/introduction/authentication API keys, JWT public keys, and how to authenticate every Struct request. Struct authenticates every REST, websocket, and webhook management request with an API key. Two key types are supported: | Prefix | Use from | Description | | ---------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `sk_live_` | Server-side code only | Secret key. Full access to your organisation. Inherits the org's plan, rate limit, and credit cap. | | `pk_jwt_` | Browsers, mobile apps, any untrusted client | Public key. Useless without a valid JWT from your configured auth provider. Safe to ship in frontend bundles. | Generate keys from the [Dashboard](https://struct.to/dashboard). Secret keys are shown only once at creation. ## REST Send the key in the `X-API-Key` header on every request: ```bash theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: sk_live_YOUR_KEY" ``` For JWT public-key auth, send both credentials: ```bash theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: pk_jwt_YOUR_KEY" \ -H "Authorization: Bearer USER_JWT" ``` ## WebSocket Browsers can't send custom headers on WebSocket connections, so credentials are passed as query parameters: ``` wss://api.struct.to/ws?api-key=sk_live_YOUR_KEY wss://api.struct.to/ws/alerts?api-key=sk_live_YOUR_KEY ``` JWT public-key auth adds a `token` parameter: ``` wss://api.struct.to/ws?api-key=pk_jwt_YOUR_KEY&token=USER_JWT ``` ## SDK The TypeScript SDK accepts the same credentials. The same `apiKey` field accepts secret and public keys; pass `jwt` (or a rotating `getJwt`) alongside `pk_jwt_*`. ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_YOUR_KEY", }); const userClient = new StructClient({ apiKey: "pk_jwt_YOUR_KEY", jwt: session.accessToken, }); ``` ```bash cURL theme={null} curl https://api.struct.to/v1/polymarket/market \ -H "X-API-Key: sk_live_YOUR_KEY" ``` ```python Python theme={null} import requests response = requests.get( "https://api.struct.to/v1/polymarket/market", headers={"X-API-Key": "sk_live_YOUR_KEY"}, ) ``` See [SDK Authentication](/sdk/authentication) for the JWT rotation pattern. ## Rotating keys To rotate a secret key: 1. Generate a new `sk_live_*` key in the dashboard. 2. Roll the new key out to your servers. 3. Disable the old key in the dashboard. Disabled keys reject new requests immediately. Open websocket connections stay live until they disconnect normally; reconnect attempts will fail authentication. JWT public keys rotate transparently when you point them at a new JWKS URL. Cached JWKS entries are refreshed every 5 minutes, or immediately when a JWT presents an unknown `kid`. ## JWT public keys `pk_jwt_*` keys let your end users authenticate directly from a browser or mobile app using a JWT from your auth provider (Privy, Auth0, Clerk, Turnkey, Google, custom). The Struct API verifies the JWT signature against your configured public key and applies per-user rate limits keyed on the JWT `sub` claim. See [Frontend Auth (JWT)](/api-reference/jwt-auth) for the full flow, supported algorithms, and JWKS configuration. Manage keys at [Dashboard › JWT Keys](/dashboard/jwt-keys). Symmetric algorithms (HS256, HS384, HS512) are **not supported**. Only asymmetric families (RS\*, ES\*, EdDSA) are accepted, because your public key needs to be safe to publish. ## Error responses | Status | Meaning | Action | | ----------------------- | ------------------------------------------------ | ----------------------------------------------------------- | | `401 Unauthorized` | Missing or malformed credentials. | Check the `X-API-Key` header is present and not empty. | | `401 Unauthorized` | Invalid key, expired JWT, or signature mismatch. | Verify the key value and JWT signing algorithm. | | `403 Forbidden` | Key disabled or org over its credit cap. | Re-enable the key or top up credits in the dashboard. | | `429 Too Many Requests` | Rate limit exceeded. | Back off and retry. See [Rate Limits](/guides/rate-limits). | On WebSocket, auth failures arrive as the `auth_failed` event (SDK) or a close frame with code `1008`. Reconnect attempts are not retried automatically on auth failures, since the credentials need to be fixed first. ## Security checklist * Use `sk_live_*` only from servers you control. Never embed in frontend bundles, mobile apps, or public repos. * Use `pk_jwt_*` for every browser-side or mobile call. Pair with a short-lived JWT (15 minutes or less) signed by your auth provider. * Scope work to multiple keys when you have distinct environments or services so you can rotate without coordinated downtime. * Monitor key usage in the dashboard. A key that suddenly spikes is the first sign it's been leaked. # Concepts Source: https://docs.struct.to/introduction/concepts Key terms and entities in the Struct Polymarket data model. ## Core Entities ### Events A top-level question or topic on Polymarket. An event contains one or more **markets** (outcomes). For example, "Who will win the 2024 US Presidential Election?" is an event, and the individual candidates are markets within it. Events can be filtered by status, category, tags, and series. They include aggregate trading metrics and metadata like whether they're active, featured, or live. ### Markets A single tradeable outcome within an event. Most markets have a binary yes/no structure, with a price between 0 and 1 representing the implied probability. Markets are identified by their **condition ID**. For example, within the presidential election event, "Will Trump win?" is one market and "Will Harris win?" is another. ### Outcomes The specific answers or results that can occur within a market. While most markets have a standard binary "Yes" and "No" outcome structure, multiple choice and esports markets often use non-standard outcome names (such as team names or specific candidates). Each outcome is represented by its own token and has a unique `position_id`. ### Series A grouping of related events that recur over time. Weekly "Bitcoin price" events or a seasonal set of political events might be grouped into a series. Series can track external price feeds via Pyth or Chainlink integrations. ### Tags Labels used to categorize events by topic (e.g., "US Politics", "Crypto", "Sports"). Events can have multiple tags. Use tags to filter and discover events. ## Trading Concepts ### Shares Tokenized units representing a claim on a specific outcome. If the outcome resolves in favor of your shares (e.g. "Yes"), each share pays out 1 pUSD. If it resolves against them (e.g. "No"), each share is worth 0. The price of a share reflects the market's implied probability. ### Trades An on-chain event on a market's trade feed. The most common case is a buy or sell of outcome shares, which records the price, size, side, and the trader who executed it. The feed also covers position management and protocol payouts. The on-chain trade types: | Type | Description | | -------------------------------------- | --------------------------------------------------------------------- | | **OrderFilled** | A standard trade matched on the CLOB. | | **OrdersMatched** | Multiple maker orders filled against a taker in a single match. | | **Redemption** | Redeeming resolved shares for pUSD after a market settles. | | **Merge** | Merging complementary outcome tokens back into collateral. | | **Split** | Splitting collateral into a full set of complementary outcome tokens. | | **PositionsConverted** | A neg-risk conversion of a position across outcomes. | | **Cancelled** | An open order cancelled before it filled. | | **RegisterToken** | A new outcome token registered on-chain when a market is set up. | | **MakerRebate**, **Reward**, **Yield** | pUSD credits paid directly to a trader's wallet. | These are the actual on-chain trades. The trade feed also carries protocol lifecycle (oracle) events such as proposals, disputes, and resolutions. For the full discriminated union and per-variant fields, see [Trade Types](/sdk/trade-types). ### Volume The total size of trades over a window. Struct payloads expose volume two ways: | Field | Unit | When to use | | --------------- | ----------------------------------- | ------------------------------------ | | `shares_volume` | Notional shares or contracts traded | Matching figures shown on Polymarket | | `usd_volume` | Dollar value of those trades | True USD throughput | Polymarket's UI labels volume as **notional** (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct. `usd_volume` is the dollar value of those same trades. The two diverge because shares trade between 0 and 1 pUSD: 1,000 shares filled at 0.65 each is a `shares_volume` of 1,000 and a `usd_volume` of 650. Every metric stream (market, event, position, condition, tag) reports both, alongside builder-attributed counterparts (`builder_usd_volume`, `builder_shares_volume`) covering activity routed through builder-signed orders. ### Bonds A view of markets through a fixed-income lens. The bonds endpoint surfaces markets where the expected return and time-to-resolution create a yield-like profile, including **APY** (annualized percentage yield) and **return percentage**. ### Candlesticks Standard OHLCV (Open, High, Low, Close, Volume) price bars for charting a market's price history. Available in resolutions from 1 minute to 1 day. ### Holders Traders who currently hold shares in a specific market outcome. The holders endpoint provides position sizes, average entry prices, and unrealized PnL. ### Resolution The process of determining the final outcome of a market. Once resolved, winning shares can be redeemed for 1 pUSD each. Markets can be resolved manually or automatically. ## Identifiers ### condition\_id The primary identifier for a market. Derived from Polymarket's Conditional Token Framework (CTF) smart contracts on Polygon. Every endpoint that references a specific market uses this ID. ### position\_id The token ID for a specific outcome within a market. Each outcome (e.g., "Yes", "No", or a team name) has its own position ID, corresponding to an ERC-1155 token on Polygon. Used for candlestick queries and trade filtering. ### slug A URL-friendly, human-readable string used to identify events, markets, series, and tags. Example: `will-bitcoin-hit-100k-by-december`. Used as a path parameter in several endpoints. ## Infrastructure Terms ### pUSD and USDC.e Polymarket settles trades in **pUSD**, its own dollar-pegged stablecoin issued on Polygon. pUSD is the active collateral for new trades, positions, and PnL across the platform. **USDC.e** (bridged USD Coin on Polygon) remains supported for legacy balances and on-ramps and still appears on accounts that haven't migrated. Both are pegged 1:1 to the US Dollar, so monetary values are interchangeable in dollar terms. The accounts Websocket room exposes balances for both via separate `pusd_update` and `usdce_update` events so you can track holdings independently. ### CLOB Central Limit Order Book. Polymarket's order-matching system that matches buy and sell orders at specific prices, as opposed to an AMM (Automated Market Maker). ### Neg Risk A framework for multi-outcome events where the sum of all outcome probabilities must equal 1. Buying "No" on one outcome is economically equivalent to buying "Yes" on all other outcomes. Used for events with more than two mutually exclusive outcomes. ### Combos (parlays) A **combo** is a multi-leg market whose payout depends on several underlying outcomes resolving together. Struct classifies combo markets with `combo_type`: `combinatorial` (parlay). A combo is one instrument with one entry price, so PnL is tracked at the combo level rather than per leg. Use the [trader combo PnL endpoints](/guides/trader-combo-pnl) to list a wallet's parlays and expand each into its legs, implied probability, and potential payout. ### PnL Profit and Loss, calculated **mark-to-market** at the block level: a position's value is the current market value of your shares minus what you paid, recomputed on every block. PnL reflects on-chain reality from the moment you buy, with no sell or resolution required. * **Unrealized PnL** is the live paper profit/loss on shares you still hold, updated each block as the market price moves. * **Realized PnL** is the locked-in result from reducing a position, settling on every sell, merge, or redemption. * **Total PnL** combines the two into a single net figure (`total_pnl_usd`), the headline number for a wallet. The figure folds in merges, splits, neg-risk conversions, redemptions, fees, maker rebates, rewards, and yield, so it is the actual economic outcome of holding a position rather than just price times shares minus cost. It is a more complete figure than Polymarket's own display, which omits fees, rebates, rewards, and yield. This true net PnL is what the [trader leaderboards](/api-reference/trader/get-global-pnl-leaderboard) rank on (by default `total_pnl_usd`), so a board reflects what traders have actually earned rather than how much they have traded. The global leaderboard and the per market, position, and category boards all share the same accounting, and a wallet's standing always matches its own [PnL summary](/api-reference/trader/get-trader-pnl-summary). For worked examples, see the [live trader PnL dashboard](/guides/live-trader-pnl-dashboard), [combo (parlay) PnL](/guides/trader-combo-pnl), and [PnL history charts](/guides/trader-pnl-history-charts) guides. # Pricing & Ratelimits Source: https://docs.struct.to/introduction/pricing Plans, credits, and rate limits for the Struct API All plans are billed in **credits** which reference roughly how much compute it costs to run that specific API or Websocket. All plans include access to all APIs. ## Pricing Plans | Plan | Price | Credits | API keys | Webhooks | WS connections | | ---------- | -------- | --------- | --------- | --------- | -------------- | | Free | Free | 5,000 | 1 | 10 | 1 | | Hobby | \$49/mo | 100,000 | 3 | 5,000 | 50 | | Startup | \$299/mo | 1,000,000 | 10 | 25,000 | 250 | | Scale | \$999/mo | 5,000,000 | 25 | 100,000 | 1,000 | | Enterprise | Custom | Custom | Unlimited | Unlimited | Unlimited | ## Rate Limits | Plan | Requests per Second | Requests per Minute | | ---------- | ------------------- | ------------------- | | Free | 5 | 120 | | Hobby | 25 | 1,000 | | Startup | 125 | 5,000 | | Scale | 300 | 15,000 | | Enterprise | Custom | Custom | ## Webhook Subscriptions | Plan | Max webhooks | | ---------- | ------------ | | Free | 10 | | Hobby | 5,000 | | Startup | 25,000 | | Scale | 100,000 | | Enterprise | Unlimited | Webhooks are billed at **0.1 or 0.2 credits per delivered event** depending on the event type. See the [Webhook Pricing](/webhooks/pricing) page for the full per-event rate card. ## Websocket Connections | Plan | Concurrent connections | | ---------- | ---------------------- | | Free | 1 | | Hobby | 50 | | Startup | 250 | | Scale | 1,000 | | Enterprise | Unlimited | ## Credits Overview At the core, all APIs are charged at **1 credit per call**. This can vary depending on internal costs and how much data is being processed. Example list of exclusions to this rule: | API Name | Cost | | -------- | -------------------------------- | | Search | 1 credit per search type (max 3) | ## Websocket Pricing Websockets are billed **per message**, with rates that vary by room. Opening a new connection places a 1 credit hold that is charged even if you disconnect immediately. Because credits are fractional, any usage is rounded up to the nearest whole credit at the end of the billing period. See the [Websocket Pricing](/websockets/pricing) page for the full per-room rate card and example calculations. # Welcome to Struct Source: https://docs.struct.to/introduction/welcome The fastest API for Polymarket data. Struct is the fastest API for Polymarket. **Queries are instant, no matter how active the trader or market.** Every derived value (PnL, leaderboards, positions, market and event metrics) is pre-computed and stored as a single materialised row, updated as on-chain events arrive, so what would otherwise be a scan over hundreds of thousands of trades is a single-row lookup. Get your API key and make your first request in minutes. Secret keys, JWT public keys, and how to call the API from a browser. Browse every endpoint, parameter, and response shape. Manage your organisation, keys, webhooks, and billing. ## Why Struct? PnL, leaderboards, positions, and metrics are pre-computed as a single row. Every query is a one-row read, not a scan over months of trades. REST, websockets, and webhooks for every Polymarket entity in one place. No more stitching together gamma, CLOB, the subgraph, and on-chain reads. Push delivery for trades, prices, PnL, positions, and order books over websockets, plus 30+ in-app alert types and 30+ webhook event types. ## Struct vs. Polymarket's direct APIs | | Polymarket (Gamma + Data + CLOB) | Struct | | ------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | **Surface** | Three separate APIs plus the subgraph and on-chain reads | One unified REST, websocket, and webhook API | | **Trader PnL** | Available; does not include fees, win rate, open positions, etc | Mark-to-market and fee-accurate, with win rate, hold time, best/worst trade, splits, merges, and more | | **Leaderboards** | Materialised, refreshed on a periodic cycle | Materialised and updated in real time as trades settle | | **Trades stream** | Available; thin payload (raw on-chain fields) | Enriched payload with slug, event, image, builder code, fee breakdown, trader pseudonym, neg-risk context | | **Pending trades** | Wait for chain confirmation | Streamed from the mempool before they settle | | **Per-wallet position stream** | User channel, requires that wallet's auth | Any wallet, no per-wallet auth, server-side filtered | | **Webhooks** | Not available | 30+ event types with HMAC signing, retries, rotation, delivered instantly, no polling | | **In-app alerts** | Not available | Whale trades, PnL crossings, probability/price spikes, volume milestones over WS or HTTP | | **JWT public-key auth** | Not supported | First-class, JWKS or inline key, per-user limits | ## Use cases Custom trading UIs, aggregators, and copy-trading powered by real-time Polymarket data. Autonomous agents that research, monitor, and trade on prediction markets. Dashboards, quant research, and large-scale historical analysis. ## Start building Typed clients for REST, rooms, alerts, and webhook management. Subscribe to trades, prices, PnL, positions, order books, and metric streams. HTTP event delivery for trades, milestones, spikes, PnL crossings, and market lifecycle. Best practices, error handling, rate limits, and recipes for common integrations. # Alerts WS Source: https://docs.struct.to/sdk/alerts Typed subscriptions on the alerts websocket with StructAlertsWebSocket. `StructAlertsWebSocket` connects to the alerts endpoint (`wss://api.struct.to/ws/alerts`) and surfaces per-event typed filters and payloads. Unlike rooms, alerts use a single flat subscribe-by-event protocol with no join step. ## Connect ```typescript theme={null} import { StructAlertsWebSocket } from "@structbuild/sdk"; const alerts = new StructAlertsWebSocket({ apiKey: "sk_live_xxx" }); await alerts.connect(); ``` The config shape, reconnect behaviour, and lifecycle events match `StructWebSocket`. See [WebSockets](/sdk/websockets) for shared configuration. ## Subscribe Each alert event has its own filter type. Pass the event name as the first argument; the second argument is typechecked against the matching schema. ```typescript theme={null} await alerts.subscribe("trader_whale_trade", { wallet_addresses: ["0xd91..."], min_usd_value: 10_000, }); await alerts.subscribe("price_spike", { spike_direction: "up", min_price_change_pct: 5, }); await alerts.subscribe("market_volume_milestone", { condition_ids: ["0xabc..."], timeframes: ["1h", "24h"], milestone_amounts: [100_000, 1_000_000], }); ``` `subscribe` returns a promise that resolves with `{ op: "subscribed", event, subscription_id }` once the server acknowledges. Subsequent subscribes on the same event replace the previous filter. ## Listen for events Payloads arrive as `{ event, timestamp, data }`. The `data` shape is narrowed by the event name you listened to. ```typescript theme={null} alerts.on("trader_whale_trade", (payload) => { payload.data.trader; payload.data.amount_usd; }); alerts.on("price_spike", (payload) => { payload.data.spike_direction; payload.data.spike_pct; }); alerts.on("market_created", (payload) => { payload.data.condition_id; payload.data.market_slug; }); ``` ## Unsubscribe ```typescript theme={null} alerts.unsubscribe("trader_whale_trade"); alerts.disconnect(); ``` ## Lifecycle events `StructAlertsWebSocket` emits the same lifecycle events as the rooms socket: `connected`, `disconnected`, `reconnecting`, `reconnect_failed`, `auth_failed`, `error`, `warning`. ```typescript theme={null} alerts.on("connected", () => console.log("alerts live")); alerts.on("auth_failed", (err) => console.error(err)); ``` ## Available alerts See [Alerts](/websockets/alerts/getting-started) for the full list of events, filters, and payload schemas. Every event documented there is supported by `StructAlertsWebSocket.subscribe` with full TypeScript types. Common alerts include: * `trader_first_trade`, `trader_new_market`, `trader_new_trade`, `trader_trade_event`, `trader_whale_trade` * `trader_global_pnl`, `trader_market_pnl`, `trader_event_pnl` * `condition_metrics`, `event_metrics`, `position_metrics`, `tag_metrics` * `market_volume_milestone`, `event_volume_milestone`, `position_volume_milestone` * `price_spike`, `market_volume_spike`, `event_volume_spike`, `position_volume_spike` * `close_to_bond`, `market_created`, `oracle_events` * `asset_price_tick`, `asset_price_window_update` # Authentication Source: https://docs.struct.to/sdk/authentication API key and JWT public key auth with the SDK. The SDK supports both key types you can generate in the [dashboard](https://struct.to/dashboard): * **Secret keys** (`sk_*`) for server-side code, giving full access to your organisation. * **JWT public keys** (`pk_jwt_*`) for client-side code, where each end user supplies their own JWT. Both are configured through the `apiKey` field on every client. ## Secret API key Use secret keys from trusted server environments only. They authenticate as your organisation and inherit its rate limits and credit cap. ```typescript theme={null} import { StructClient, StructWebSocket, StructAlertsWebSocket } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); const alerts = new StructAlertsWebSocket({ apiKey: "sk_live_xxx" }); ``` Never embed `sk_*` keys in frontend bundles, mobile apps, or public repositories. Use JWT public keys for any environment you do not fully control. ## JWT public key auth JWT public key auth lets end users call the API with a JWT from your auth provider (Privy, Auth0, Clerk, Google, Turnkey, and so on) without exposing a secret key. You create a `pk_jwt_*` key in the dashboard, configure it with your JWKS URL or inline public key, then pass it alongside the user's JWT. See [Frontend Auth (JWT)](/api-reference/jwt-auth) for the full auth flow. ### REST ```typescript theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", jwt: userAccessToken, }); ``` The SDK sends both credentials on every request: ```http theme={null} X-API-Key: pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 Authorization: Bearer ``` ### WebSocket ```typescript theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", jwt: userAccessToken, }); ``` Both credentials are appended as query params: ``` wss://api.struct.to/ws?api-key=pk_jwt_xxx&token= ``` ### Rotating JWTs A `jwt` value is captured when the socket is constructed. If your JWT can rotate while the socket stays alive (for example, refreshed by your auth SDK), pass `getJwt` instead. It is invoked on every reconnect so the new URL always carries a fresh token. ```typescript theme={null} const ws = new StructWebSocket({ apiKey: "pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", getJwt: () => auth.getAccessToken(), }); ``` `getJwt` takes precedence over `jwt` when both are provided. ## Shipping the key to the browser `pk_jwt_*` keys are safe to hardcode in frontend bundles. They are useless without a valid JWT signed by your configured auth provider. ```typescript theme={null} const client = new StructClient({ apiKey: process.env.NEXT_PUBLIC_STRUCT_JWT_KEY!, jwt: session.accessToken, }); ``` ## Errors Auth failures surface as: * **REST**: `HttpError` with status `401`. * **WebSocket**: the `auth_failed` event, followed by `disconnected`. The socket will not attempt to reconnect on auth failures. See [Errors](/sdk/errors) for the full error hierarchy. # Configuration Source: https://docs.struct.to/sdk/configuration Configure the StructClient with auth, timeouts, retries, and hooks. All constructors accept a single `StructClientConfig` object: ```typescript theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx", timeout: 10_000, retry: { maxRetries: 3, initialDelayMs: 500, maxDelayMs: 30_000, }, headers: { "X-App-Version": "1.4.0", }, onRequest: ({ method, url }) => console.log(`${method} ${url}`), onResponse: ({ status, durationMs }) => console.log(`${status} in ${durationMs}ms`), }); ``` ## Options | Option | Type | Default | Description | | ------------ | --------------------------------- | -------------------------- | -------------------------------------------------------------------------- | | `apiKey` | `string` | required | Secret (`sk_*`) or JWT public key (`pk_jwt_*`). Sent as `X-API-Key`. | | `jwt` | `string` | (none) | User JWT, sent as `Authorization: Bearer`. Only used with `pk_jwt_*` keys. | | `baseUrl` | `string` | `https://api.struct.to/v1` | Override the REST base URL. | | `headers` | `Record` | (none) | Extra headers merged into every request. | | `timeout` | `number` | `30_000` | Per-request timeout in milliseconds. Enforced with `AbortController`. | | `retry` | `RetryConfig` | off | Automatic retry on `429` and `5xx`. Disabled when omitted. | | `onRequest` | `(info) => void \| Promise` | (none) | Called before every request. | | `onResponse` | `(info) => void \| Promise` | (none) | Called after every response. | ## Retry Pass a `retry` block to enable exponential backoff on retryable failures (`429`, `500`, `502`, `503`, `504`, `NetworkError`, `TimeoutError`). `Retry-After` headers on `429` responses are honoured when present. ```typescript theme={null} const client = new StructClient({ apiKey: "sk_live_xxx", retry: { maxRetries: 3, initialDelayMs: 500, maxDelayMs: 10_000, }, }); ``` | Field | Default | Description | | ---------------- | -------- | ------------------------------------------------- | | `maxRetries` | `3` | Maximum retry attempts after the initial request. | | `initialDelayMs` | `1000` | Base delay, doubled on each attempt. | | `maxDelayMs` | `30_000` | Upper bound on the computed delay. | ## Hooks `onRequest` and `onResponse` fire around every HTTP call and are useful for logging, tracing, and metrics. Hook errors are swallowed so they never break a request. ```typescript theme={null} const client = new StructClient({ apiKey: "sk_live_xxx", onRequest: ({ method, url, headers }) => { tracer.start({ method, url }); }, onResponse: ({ method, url, status, durationMs, headers }) => { tracer.end({ method, url, status, durationMs }); }, }); ``` ## Custom base URL Enterprise customers with a dedicated domain can point the SDK at their own host by overriding `baseUrl`. Include the `/v1` prefix; the SDK does not add it for you. ```typescript theme={null} const client = new StructClient({ apiKey: "sk_live_xxx", baseUrl: "https://api.yourcompany.com/v1", }); ``` ## WebSocket configuration `StructWebSocket` and `StructAlertsWebSocket` share a similar config shape, plus reconnect and subscribe-timeout options. See [WebSockets](/sdk/websockets) for details. ```typescript theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx", baseUrl: "wss://api.yourcompany.com", subscribeTimeout: 10_000, reconnect: { maxRetries: 10, initialDelayMs: 500, maxDelayMs: 30_000, }, }); ``` # Errors Source: https://docs.struct.to/sdk/errors The SDK error hierarchy and how to handle failures. Every error thrown by the SDK extends `StructError`, so you can use `instanceof` checks to branch on failure mode without inspecting error messages. ```typescript theme={null} import { StructError, HttpError, NetworkError, TimeoutError, WebSocketError, WebSocketClosedError, } from "@structbuild/sdk"; ``` ## Hierarchy ``` StructError ├── HttpError ├── NetworkError ├── TimeoutError └── WebSocketError └── WebSocketClosedError ``` | Error | Thrown when | | ---------------------- | ------------------------------------------------------------------------------------------------------ | | `HttpError` | The server returned a non-2xx response. Exposes `status`, `statusText`, `body`, and `responseHeaders`. | | `NetworkError` | `fetch` failed before a response arrived (DNS failure, TLS error, connection refused, and so on). | | `TimeoutError` | The request exceeded the configured `timeout` (default 30s) and was aborted. | | `WebSocketError` | A websocket operation failed (subscribe timeout, unexpected protocol message, listener threw). | | `WebSocketClosedError` | The websocket was closed unexpectedly. Exposes `code` and `reason`. | ## REST ```typescript theme={null} import { HttpError, TimeoutError, NetworkError } from "@structbuild/sdk"; try { const market = await client.markets.getMarket({ conditionId: "0x..." }); } catch (error) { if (error instanceof HttpError) { if (error.status === 404) { return null; } if (error.status === 429) { console.warn("Rate limited", error.responseHeaders?.get("retry-after")); } console.error(error.status, error.body); } else if (error instanceof TimeoutError) { console.error("Request timed out"); } else if (error instanceof NetworkError) { console.error("Network error", error.cause); } else { throw error; } } ``` ### HttpError ```typescript theme={null} class HttpError extends StructError { readonly status: number; readonly statusText: string; readonly body: unknown; readonly responseHeaders: Headers | undefined; } ``` `body` is the parsed JSON response when the server returned JSON. Otherwise it is the raw response text. ## Retry integration When `retry` is configured on the client, `HttpError` responses with retryable status codes (`429`, `500`, `502`, `503`, `504`), `NetworkError`, and `TimeoutError` are retried automatically before the error reaches your code. `Retry-After` headers are honoured. See [Configuration](/sdk/configuration). ## WebSocket WebSocket errors are surfaced through the `error` event on the socket. For operations that return promises (`connect`, `subscribe`), rejected promises carry `WebSocketError` or `WebSocketClosedError`. ```typescript theme={null} ws.on("error", (err) => { if (err instanceof WebSocketClosedError) { console.error("closed", err.code, err.reason); } else { console.error(err); } }); try { await ws.subscribe("polymarket_trades", { condition_ids: ["0xabc..."] }); } catch (err) { if (err instanceof WebSocketError) { console.error("subscribe failed", err.message); } } ``` ## Listener errors If an event listener throws, the SDK forwards the error to any `error` listeners and logs it to the console as a fallback. Listener exceptions never crash the socket. # Getting Started Source: https://docs.struct.to/sdk/getting-started Install the @structbuild/sdk TypeScript SDK and make your first call. ## Overview `@structbuild/sdk` is the official TypeScript SDK for the Struct API. It wraps the REST API, the rooms websocket, the alerts websocket, and webhook management in a single, fully-typed client. The SDK is dual-published as ESM and CJS, runs in Node, Bun, Deno, and browsers, and ships with generated types derived directly from the live OpenAPI and AsyncAPI specs. Source: [github.com/structbuild/struct-typescript-sdk](https://github.com/structbuild/struct-typescript-sdk). For a high-level tour of every surface and a feature matrix versus raw HTTP, see [SDK Overview](/sdk/overview). ## Install ```bash npm theme={null} npm install @structbuild/sdk ``` ```bash bun theme={null} bun add @structbuild/sdk ``` ```bash pnpm theme={null} pnpm add @structbuild/sdk ``` ```bash yarn theme={null} yarn add @structbuild/sdk ``` ## Quickstart Sign up at [struct.to/dashboard](https://struct.to/dashboard) and create an organisation. Open the [API Keys](https://struct.to/dashboard) page in your dashboard and create a new key. Copy the value somewhere safe; you won't be able to view it again. See [Authentication](/introduction/authentication) for key types, JWT public keys, and rotation. ```typescript theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx", }); ``` ```typescript theme={null} const { data: markets } = await client.markets.getMarkets({ limit: 10 }); for (const market of markets) { console.log(market.slug, market.last_price); } ``` ```typescript theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trades"); ws.on("trade_stream_update", (event) => { console.log(event.condition_id, event.price); }); ``` ## What's included Namespaced access to every endpoint: markets, events, trader, holders, order book, series, assets, tags, bonds, search, and webhooks. Typed `subscribe` / `on` for every room, with auto-reconnect, replay, and keepalive. Per-event typed filters for the alerts websocket, sharing the same connection primitives. Authenticate your end users with their own JWTs without shipping `sk_*` keys to the browser. Iterate through any paginated endpoint with a single async generator. `HttpError`, `TimeoutError`, `NetworkError`, `WebSocketError`, and friends. ## Requirements The SDK targets modern runtimes that support the Fetch API, `AbortController`, and native `WebSocket`. That covers Node 18+, Bun, Deno, Cloudflare Workers, and every evergreen browser. No polyfills are required. ## Next steps * Configure the client: see [Configuration](/sdk/configuration). * Browse the REST reference: see [REST API](/sdk/rest-api). * Stream live data: see [WebSockets](/sdk/websockets). # Overview Source: https://docs.struct.to/sdk/overview Typed, batteries-included clients for the Struct API. The fastest path to a Struct integration is the official SDK. It wraps every REST endpoint, both websocket endpoints, and webhook management in a single typed client. Types are generated directly from the live OpenAPI and AsyncAPI specs, so they match production exactly. ## Available SDKs `@structbuild/sdk` runs in Node 18+, Bun, Deno, Cloudflare Workers, and every evergreen browser. Dual-published ESM/CJS, zero polyfills. The REST and websocket protocols are language-agnostic. Use any HTTP and WebSocket client; the type definitions in the OpenAPI and AsyncAPI specs are public. ## What's included Namespaced access to every endpoint with typed parameters and responses. Typed `subscribe` and `on` per room, with auto-reconnect, replay, and keepalive. Per-event typed filters sharing the same connection primitives. Authenticate end users with their own JWTs. Safe to ship in the browser. Iterate any cursor-paginated endpoint with an async generator. `HttpError`, `TimeoutError`, `NetworkError`, `WebSocketError`, and friends. Create, list, rotate, and delete webhook subscriptions from code. Built-in exponential backoff and `onRequest` / `onResponse` lifecycle hooks. ## At a glance ```typescript theme={null} import { StructClient, StructWebSocket } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: markets } = await client.markets.getMarkets({ limit: 10 }); const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); await ws.subscribe("polymarket_trades", { condition_ids: ["0xabc..."] }); ws.on("trade_stream_update", (event) => { console.log(event.condition_id, event.price, event.side); }); ``` ## Why use the SDK? | | Raw HTTP / WebSocket | TypeScript SDK | | ---------------- | ------------------------------------- | --------------------------------------------------- | | **Types** | Hand-rolled or generated from OpenAPI | Generated, shipped, versioned with the package | | **Auth** | Set headers / query params manually | `apiKey` / `jwt` / `getJwt` config fields | | **Pagination** | Loop on `pagination_key` yourself | Async iterator per endpoint | | **Reconnects** | Exponential backoff yourself | Built in, with auto-replay of subscriptions | | **Retries** | Implement around `fetch` | Configurable retry block with `Retry-After` support | | **Errors** | Parse status codes and bodies | Typed error hierarchy with `instanceof` checks | | **JWT rotation** | Reconstruct URL on every reconnect | Pass `getJwt`, the SDK handles the rest | Everything the SDK does is supported by the raw protocols, so you can drop down to plain HTTP or WebSocket whenever you need to. ## Next steps * [Getting Started](/sdk/getting-started): install and instantiate the client. * [Configuration](/sdk/configuration): timeouts, retries, hooks, custom base URL. * [Authentication](/sdk/authentication): secret keys and JWT public-key flow. * [REST](/sdk/rest-api), [Rooms WS](/sdk/websockets), [Alerts WS](/sdk/alerts). # Pagination Source: https://docs.struct.to/sdk/pagination Iterate through every page of a list endpoint with the paginate helper. List endpoints return a single page plus a `pagination` block on the response envelope: ```typescript theme={null} const { data, pagination } = await client.markets.getMarkets({ limit: 100 }); pagination?.has_more; pagination?.pagination_key; ``` You can walk pages manually by feeding `pagination_key` into the next call, or use the `paginate` helper to iterate through every item as a stream. ## paginate `paginate(fetcher, params, pageSize?)` is an async generator that lazily pulls each page and yields individual items. It terminates when the server reports `has_more: false` or returns a null `pagination_key`. ```typescript theme={null} import { StructClient, paginate } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); for await (const market of paginate( (params) => client.markets.getMarkets(params), { tags: "politics" }, 100, )) { console.log(market.slug, market.last_price); } ``` | Argument | Type | Description | | ---------- | ---------------------------------------- | -------------------------------------------------------------------------------- | | `fetcher` | `(params) => Promise>` | A namespace method bound to the client. | | `params` | base params object | Everything except `limit` and `pagination_key`, which are managed by the helper. | | `pageSize` | `number` | Optional page size. Defaults to `100`. | Any list-style namespace method works: ```typescript theme={null} for await (const trade of paginate( (params) => client.markets.getTrades(params), { condition_ids: "0xabc..." }, )) { } for await (const event of paginate( (params) => client.events.getEvents(params), { tags: "politics" }, )) { } for await (const trader of paginate( (params) => client.trader.getGlobalPnl(params), {}, )) { } ``` ## Early termination You can stop iterating at any time. Exiting the `for await` loop stops fetching the next page, so you only pay for what you read. ```typescript theme={null} let count = 0; for await (const trade of paginate( (params) => client.markets.getTrades(params), { condition_ids: "0xabc..." }, )) { if (++count >= 500) break; } ``` ## Manual pagination If you prefer to walk pages yourself (for example, to render a paginated UI), skip the helper and drive the cursor directly: ```typescript theme={null} let cursor: string | number | undefined; while (true) { const page = await client.markets.getMarkets({ limit: 100, pagination_key: cursor, }); render(page.data); if (!page.pagination?.has_more || page.pagination.pagination_key == null) break; cursor = page.pagination.pagination_key; } ``` ## Offset vs `pagination_key` A few endpoints (the trader trades and PnL methods) also accept an `offset` param that skips a fixed number of rows. It exists for one case: server-rendered, deep-linkable pages, where `?page=42` maps to `offset=4100` and you need to render that slice on the first request without replaying every page to obtain a cursor. ```typescript theme={null} const page = await client.trader.getTraderTrades({ address: "0x...", limit: 100, offset: 4100, }); ``` `offset` is capped at a few thousand rows (typically in the 3,000 to 5,000 range), so it cannot reach deep into a large dataset. For everything else, prefer `pagination_key`. Cursor pagination is the right default for any programmatic consumer, while `offset` is reserved for rendering a specific page directly from a URL. When both `offset` and `pagination_key` are supplied on the same request, `offset` takes precedence and the cursor is ignored. The [`paginate`](#paginate) helper always uses cursor pagination, so it never sends an `offset`. See the [API Reference](/api-reference/pagination) for the underlying query parameters. For a complete worked backfill, see [Fetching an entire dataset](/guides/fetching-all-data). # REST API Source: https://docs.struct.to/sdk/rest-api Every REST namespace and method exposed by StructClient. Every `StructClient` instance exposes 11 namespaces, each a thin wrapper around a group of REST endpoints. Methods share the same shape: ```typescript theme={null} await client.{namespace}.{method}(params?): Promise> ``` * `params` is a typed object matching the endpoint's query and path params. * The return value is wrapped in an `HttpResponse` envelope. ## Response envelope ```typescript theme={null} interface HttpResponse { data: T; message: string | null; success: boolean; info?: { version: string; credits_consumed: number; }; pagination?: { has_more: boolean; pagination_key: string | number | null; }; } ``` All parameter and response types are re-exported from the package root. Pull them in with `import type { GetMarketsParams, MarketResponse } from "@structbuild/sdk"`. ## Markets ```typescript theme={null} client.markets.getMarkets(params?): HttpResponse client.markets.getMarket({ conditionId }): HttpResponse client.markets.getMarketBySlug({ marketSlug }): HttpResponse client.markets.getMarketChart(params): HttpResponse client.markets.getMarketMetrics(params): HttpResponse client.markets.getMarketVolumeChart(params): HttpResponse client.markets.getTrades(params?): HttpResponse client.markets.getCandlestick(params): HttpResponse client.markets.getPositionCandlestick(params): HttpResponse client.markets.getPositionMetrics(params): HttpResponse client.markets.getPositionVolumeChart(params): HttpResponse client.markets.getPriceJumps(params?): HttpResponse ``` ```typescript theme={null} const { data: markets } = await client.markets.getMarkets({ limit: 10, tags: "politics" }); const { data: market } = await client.markets.getMarket({ conditionId: "0xabc...", }); const { data: candles } = await client.markets.getCandlestick({ condition_id: "0xabc...", resolution: "1h", }); ``` Trade responses return a discriminated union. See [Trade Types](/sdk/trade-types) for how to narrow on `trade_type`. ## Events ```typescript theme={null} client.events.getEvents(params?): HttpResponse client.events.getEvent({ identifier }): HttpResponse client.events.getEventBySlug({ slug }): HttpResponse client.events.getEventChart(params): HttpResponse client.events.getEventMetrics(params): HttpResponse client.events.getEventOutcomes(params): HttpResponse> ``` ```typescript theme={null} const { data: event } = await client.events.getEventBySlug({ slug: "us-election-2028" }); const { data: metrics } = await client.events.getEventMetrics({ event_slug: "us-election-2028", timeframe: "24h", }); ``` ## Trader ```typescript theme={null} client.trader.getTraderTrades(params): HttpResponse client.trader.getTraderProfile({ address }): HttpResponse client.trader.getTraderProfilesBatch({ addresses }): HttpResponse client.trader.getTraderVolumeChart(params): HttpResponse client.trader.getTraderPnl(params): HttpResponse client.trader.getTraderMarketPnl(params): HttpResponse client.trader.getTraderEventPnl(params): HttpResponse client.trader.getTraderOutcomePnl(params): HttpResponse client.trader.getTraderPnlCandles(params): HttpResponse client.trader.getTraderPnlCalendar(params): HttpResponse client.trader.getGlobalPnl(params?): HttpResponse client.trader.getLeaderboard(params?): HttpResponse ``` ```typescript theme={null} const { data: profile } = await client.trader.getTraderProfile({ address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", }); const { data: pnl } = await client.trader.getTraderPnl({ address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", }); const { data: globalPnl } = await client.trader.getGlobalPnl({ limit: 100 }); const { data: leaderboard } = await client.trader.getLeaderboard({ timeframe: "7d", sort_by: "pnl", category: "crypto", limit: 50, }); ``` ## Holders ```typescript theme={null} client.holders.getMarketHolders(params): HttpResponse client.holders.getPositionHolders({ positionId }): HttpResponse client.holders.getMarketHoldersHistory(params): HttpResponse client.holders.getPositionHoldersHistory({ positionId }): HttpResponse ``` ```typescript theme={null} const { data: holders } = await client.holders.getMarketHolders({ condition_id: "0xabc...", }); ``` ## Order Book ```typescript theme={null} client.orderBook.getOrderBook({ position_id }): HttpResponse client.orderBook.getOrderBookHistory(params?): HttpResponse client.orderBook.getMarketOrderBook(params?): HttpResponse client.orderBook.getSpreadHistory(params?): HttpResponse ``` ```typescript theme={null} const { data } = await client.orderBook.getOrderBook({ position_id: "0xabc..." }); ``` ## Series ```typescript theme={null} client.series.getSeriesList(params?): HttpResponse client.series.getSeriesOutcomes({ series_slug }): HttpResponse> client.series.getSeriesEvents(params): HttpResponse ``` ## Assets ```typescript theme={null} client.assets.getAssetHistory(params): HttpResponse ``` ```typescript theme={null} const { data } = await client.assets.getAssetHistory({ asset_symbol: "BTC", variant: "1d", }); ``` ## Tags ```typescript theme={null} client.tags.getTags(params?): HttpResponse client.tags.getTag({ identifier }): HttpResponse ``` ## Bonds ```typescript theme={null} client.bonds.getBonds(params?): HttpResponse ``` ## Search ```typescript theme={null} client.search.search({ q }): HttpResponse ``` ```typescript theme={null} const { data: results } = await client.search.search({ q: "bitcoin", limit: 25, }); ``` ## Webhooks ```typescript theme={null} client.webhooks.list(params?): HttpResponse client.webhooks.create(body): HttpResponse client.webhooks.getWebhook({ webhookId }): HttpResponse client.webhooks.update(params): HttpResponse client.webhooks.deleteWebhook({ webhookId }): HttpResponse client.webhooks.test({ webhookId }): HttpResponse client.webhooks.rotateSecret({ webhookId }): HttpResponse client.webhooks.listEvents(): HttpResponse ``` See [Managing Webhooks](/sdk/webhooks) for full examples, filter options, and how to verify delivery signatures. # Trade Types Source: https://docs.struct.to/sdk/trade-types Narrow on trade_type to work with the Trade discriminated union. Trade endpoints (`markets.getTrades`, `trader.getTraderTrades`) return a single discriminated union covering every on-chain event type the API surfaces. The discriminant field is `trade_type`. ```typescript theme={null} import type { Trade, TradeEventType } from "@structbuild/sdk"; const { data: trades } = await client.markets.getTrades({ condition_ids: "0xabc...", }); for (const trade of trades) { switch (trade.trade_type) { case "OrderFilled": case "OrdersMatched": console.log(trade.price, trade.usd_amount, trade.shares_amount); break; case "Redemption": console.log(trade.winning_outcome_index, trade.position_details); break; case "Merge": case "Split": console.log(trade.usd_amount, trade.position_details); break; case "Resolution": console.log(trade.condition_id, trade.settled_price); break; } } ``` ## Convenience unions The SDK exports a few sub-unions for common filtering patterns: | Type | Covers | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Trade` | Every possible trade event (parent union). | | `MarketTrade` | Actual on-chain trades: `OrderFilled`, `OrdersMatched`, `Redemption`, `Merge`, `Split`, `PositionsConverted`, `Cancelled`, `RegisterToken`, `MakerRebate`, `Reward`, `Yield`. | | `OracleEvent` | Protocol lifecycle events: `Initialization`, `Proposal`, `Dispute`, `Settled`, `Resolution`, `ConditionResolution`, `Reset`, `Flag`, `Unflag`, `Pause`, `Unpause`, `ManualResolution`, `NegRiskOutcomeReported`. | | `TradeEventType` | String literal union of every `trade_type` value (useful for autocomplete and filter building). | ```typescript theme={null} import type { MarketTrade, OracleEvent } from "@structbuild/sdk"; function isMarketTrade(trade: Trade): trade is MarketTrade { return trade.trade_type !== "Initialization" && trade.trade_type !== "Proposal" && trade.trade_type !== "Resolution"; } ``` ## Individual variants Each `trade_type` also ships as its own exported schema, which is handy when you want a precise param type without switching: ```typescript theme={null} import type { OrderFilledTrade, RedemptionTrade, MergeTrade, SplitTrade, CancelledTrade, PositionsConvertedTrade, RegisterTokenTrade, MakerRebate, Reward, Yield, } from "@structbuild/sdk"; function priceOf(trade: OrderFilledTrade): number { return trade.price; } ``` Oracle lifecycle events are exported under `*Event` names: `QuestionInitializedEvent`, `AssertionMadeEvent`, `AssertionDisputedEvent`, `AssertionSettledEvent`, `QuestionResolvedEvent`, `ConditionResolutionEvent`, `QuestionResetEvent`, `QuestionFlaggedEvent`, `QuestionUnflaggedEvent`, `QuestionPausedEvent`, `QuestionUnpausedEvent`, `QuestionEmergencyResolvedEvent`, and `NegRiskOutcomeReportedEvent`. ## Filtering server-side Most trade endpoints accept a `trade_types` query parameter. Pass a comma-separated list to avoid filtering client-side: ```typescript theme={null} const { data: fills } = await client.markets.getTrades({ condition_ids: "0xabc...", trade_types: "OrderFilled,OrdersMatched", }); ``` The same filter is available on the `polymarket_trades` websocket room. See [WebSockets](/sdk/websockets). # Managing Webhooks Source: https://docs.struct.to/sdk/webhooks Create, update, test, rotate, and delete webhook subscriptions from the SDK. The `client.webhooks` namespace wraps the webhook management API. Use it to register endpoints, adjust filters, rotate signing secrets, and verify deliveries in your handler. For the full event catalogue and payload shapes, see the [Webhooks tab](/webhooks/getting-started). ## Create a webhook Each webhook subscribes to a single event and delivers to one HTTPS endpoint. Pass optional filters to narrow the deliveries and a `secret` to enable signature verification. ```typescript theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: webhook } = await client.webhooks.create({ url: "https://your-server.com/webhooks/struct", event: "price_spike", description: "Election price alerts", secret: "whsec_abc123...", filters: { event_slugs: ["us-election-2028"], min_probability_change_pct: 5, }, }); console.log(webhook.id); ``` The `secret` you provide (or one generated via `rotateSecret`) is returned once and never echoed back. Store it somewhere your handler can reach. ## List webhooks ```typescript theme={null} const { data: page } = await client.webhooks.list({ limit: 50 }); for (const hook of page.webhooks) { console.log(hook.id, hook.event, hook.status); } ``` `list` supports `limit`, `offset`, and filtering by `event` or `status`. Pagination is offset-based, so walk pages by incrementing `offset`: ```typescript theme={null} const pageSize = 50; let offset = 0; while (true) { const { data: page } = await client.webhooks.list({ limit: pageSize, offset }); for (const hook of page.webhooks) { console.log(hook.id, hook.url); } if (page.webhooks.length < pageSize) break; offset += pageSize; } ``` ## Fetch one webhook ```typescript theme={null} const { data: hook } = await client.webhooks.getWebhook({ webhookId: "whk_abc123", }); ``` ## Update a webhook `update` is a partial update. Pass only the fields you want to change. You can pause or resume a webhook by setting `status`. ```typescript theme={null} await client.webhooks.update({ webhookId: "whk_abc123", filters: { event_slugs: ["us-election-2028", "uk-election-2029"], min_probability_change_pct: 10, }, }); await client.webhooks.update({ webhookId: "whk_abc123", status: "paused", }); ``` ## Test a webhook `test` delivers a synthetic payload to the configured URL so you can confirm your handler is reachable and verifies signatures correctly. Use it any time you change the URL or the signing secret. ```typescript theme={null} const { data: result } = await client.webhooks.test({ webhookId: "whk_abc123", }); console.log(result.status_code, result.duration_ms); ``` ## Rotate the signing secret `rotateSecret` issues a new HMAC secret and invalidates the previous one immediately. The new value is returned once. ```typescript theme={null} const { data } = await client.webhooks.rotateSecret({ webhookId: "whk_abc123", }); await secrets.save("struct.webhook.signing_key", data.secret); ``` ## Delete a webhook ```typescript theme={null} await client.webhooks.deleteWebhook({ webhookId: "whk_abc123" }); ``` ## Discover available events `listEvents` returns every event type you can subscribe to, along with the filter keys each one accepts. This is the source of truth for what to pass as `event` and `filters`. ```typescript theme={null} const { data } = await client.webhooks.listEvents(); for (const info of data.events) { console.log(info.event, info.applicable_filters); } ``` ## Verifying deliveries Every delivery includes two headers your handler should inspect: | Header | Value | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `X-Webhook-ID` | UUID of the webhook subscription that fired. | | `X-Webhook-Signature` | `sha256=`, an HMAC-SHA256 of the **raw request body** using the webhook's secret. Only present when a `secret` is configured. | Recompute the HMAC over the raw bytes of the request body and compare with a constant-time check. Parsing the body to JSON before hashing will break the comparison. ```typescript Node (Express) theme={null} import crypto from "node:crypto"; import express from "express"; import type { WebhookEvent } from "@structbuild/sdk"; const app = express(); app.post( "/webhooks/struct", express.raw({ type: "application/json" }), (req, res) => { const header = req.header("x-webhook-signature") ?? ""; const [, signature] = header.split("="); const expected = crypto .createHmac("sha256", process.env.STRUCT_WEBHOOK_SECRET!) .update(req.body) .digest("hex"); const valid = signature && signature.length === expected.length && crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected), ); if (!valid) return res.status(401).end(); const delivery: WebhookEvent = JSON.parse(req.body.toString("utf8")); handle(delivery); res.status(200).end(); }, ); ``` ```typescript Next.js Route Handler theme={null} import crypto from "node:crypto"; import type { WebhookEvent } from "@structbuild/sdk"; export async function POST(req: Request) { const raw = Buffer.from(await req.arrayBuffer()); const header = req.headers.get("x-webhook-signature") ?? ""; const [, signature] = header.split("="); const expected = crypto .createHmac("sha256", process.env.STRUCT_WEBHOOK_SECRET!) .update(raw) .digest("hex"); const valid = signature && signature.length === expected.length && crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected), ); if (!valid) return new Response(null, { status: 401 }); const delivery: WebhookEvent = JSON.parse(raw.toString("utf8")); await handle(delivery); return new Response(null, { status: 200 }); } ``` ```typescript Bun / Cloudflare Workers theme={null} import type { WebhookEvent } from "@structbuild/sdk"; async function verify(secret: string, body: ArrayBuffer, header: string) { const [, provided] = header.split("="); if (!provided) return false; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign("HMAC", key, body); const expected = Array.from(new Uint8Array(mac)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); if (provided.length !== expected.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) { diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i); } return diff === 0; } export default { async fetch(req: Request, env: { STRUCT_WEBHOOK_SECRET: string }) { const raw = await req.arrayBuffer(); const header = req.headers.get("x-webhook-signature") ?? ""; if (!(await verify(env.STRUCT_WEBHOOK_SECRET, raw, header))) { return new Response(null, { status: 401 }); } const delivery: WebhookEvent = JSON.parse(new TextDecoder().decode(raw)); await handle(delivery); return new Response(null, { status: 200 }); }, }; ``` Always hash the raw body bytes, not the parsed JSON. Middleware that parses JSON before your handler runs (for example, Express's default `body-parser`) will make verification fail. Mount `express.raw` on the webhook route as shown above. ## Handling deliveries Every delivery arrives as a JSON `WebhookDeliveryEnvelope` wrapping the event-specific payload: ```typescript theme={null} import type { WebhookEvent } from "@structbuild/sdk"; ``` `WebhookEvent` is a discriminated union over every supported event, keyed on the `event` field. Each variant narrows `data` to the matching payload shape, so a single `switch` gives you full type safety across every event type you subscribe to. ```typescript theme={null} import type { WebhookEvent } from "@structbuild/sdk"; function handle(delivery: WebhookEvent) { switch (delivery.event) { case "trader_whale_trade": console.log(delivery.data.trader, delivery.data.amount_usd); break; case "price_spike": console.log( delivery.data.condition_id, delivery.data.spike_pct, ); break; case "market_created": console.log(delivery.data.condition_id, delivery.data.market_slug); break; case "trader_global_pnl": console.log(delivery.data.trader, delivery.data.realized_pnl_usd); break; case "close_to_bond": console.log(delivery.data.condition_id, delivery.data.price); break; } } ``` ### Envelope The fields on every delivery (before narrowing on `event`): | Field | Type | Description | | ------------ | ------------------------ | --------------------------------------------------------------------------------- | | `id` | `string` (UUID) | This delivery attempt. Matches the `X-Delivery-ID` header. | | `webhook_id` | `string` (UUID) | The subscription that fired. Matches `X-Webhook-ID`. | | `event` | `PolymarketWebhookEvent` | Discriminant. Test deliveries append `_test` (for example, `"price_spike_test"`). | | `data` | event-specific | Payload shape varies per event. Narrowed automatically by the `switch`. | | `timestamp` | `number` | Unix milliseconds when the delivery was created. | | `attempt` | `number` | `1` on first try, increments on each retry. | ### Per-event payload types Each payload type is exported directly for cases where you want a narrow parameter type without a `switch`: ```typescript theme={null} import type { WhaleTradePayload, ProbabilitySpikePayload, MarketCreatedPayload, FirstTradePayload, NewTradePayload, GlobalPnlPayload, MarketPnlPayload, EventPnlPayload, ConditionMetricsPayload, EventMetricsPayload, PositionMetricsPayload, VolumeMilestonePayload, EventVolumeMilestonePayload, PositionVolumeMilestonePayload, MarketVolumeSpikePayload, EventVolumeSpikePayload, PositionVolumeSpikePayload, PriceSpikePayload, CloseToBondPayload, AssetPriceTickPayload, AssetPriceWindowUpdatePayload, NewMarketPayload, WebhookTraderTradeEventPayload, } from "@structbuild/sdk"; function alertBigTrader(trade: WhaleTradePayload) { if (trade.amount_usd > 100_000) notify(trade.trader, trade.amount_usd); } ``` You can also map an event name to its payload with `WebhookEventPayloadMap`: ```typescript theme={null} import type { WebhookEventPayloadMap } from "@structbuild/sdk"; type SpikePayload = WebhookEventPayloadMap["price_spike"]; ``` ### Dispatching with a handler map For larger codebases, a record of per-event handlers keeps each one typed without a giant `switch`: ```typescript theme={null} import type { WebhookEvent, WebhookEventPayloadMap, PolymarketWebhookEvent, } from "@structbuild/sdk"; type Handlers = { [E in PolymarketWebhookEvent]?: ( data: WebhookEventPayloadMap[E], delivery: Extract, ) => Promise | void; }; const handlers: Handlers = { trader_whale_trade: async (data) => { await notify(data.trader, data.amount_usd); }, price_spike: async (data) => { await pushAlert(data.condition_id, data.spike_pct); }, market_created: async (data) => { await indexMarket(data.condition_id); }, }; async function dispatch(delivery: WebhookEvent) { const fn = handlers[delivery.event]; if (!fn) return; await (fn as (data: unknown, delivery: WebhookEvent) => Promise)( delivery.data, delivery, ); } ``` ### Ignoring test deliveries Test deliveries carry the same payload shape but append `_test` to the event name. Drop the suffix when you want to route a test through the same handlers as the real event, or reject them outright when running in production. ```typescript theme={null} function normaliseEvent(raw: string) { return raw.endsWith("_test") ? raw.slice(0, -"_test".length) : raw; } ``` ## Responding to deliveries Return any 2xx status within 10 seconds to acknowledge a delivery. Non-2xx responses and timeouts trigger retries with exponential backoff. See [Webhook Response Format](/webhooks/response-format) for the full retry policy and payload envelope. # Rooms WS Source: https://docs.struct.to/sdk/websockets Stream rooms in real time with the StructWebSocket client. `StructWebSocket` is a typed wrapper around the rooms websocket (`wss://api.struct.to/ws`). It handles connection, keepalive, reconnect, and replay of subscriptions automatically. Every room has a typed filter and a typed subscribe response. ## Connect ```typescript theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "sk_live_xxx" }); await ws.connect(); ``` `connect()` resolves once the socket reaches the `connected` state. A socket can safely be kept open for the lifetime of your app; it will auto-reconnect on transient failures. ## Subscribe ```typescript theme={null} await ws.subscribe("polymarket_trades", { condition_ids: ["0xabc..."], }); await ws.subscribe("polymarket_order_book", { condition_ids: ["0xabc..."], }); await ws.subscribe("polymarket_market_metrics", { condition_ids: ["0xabc..."], }); await ws.subscribe("polymarket_trader_pnl", { traders: ["0x1234567890abcdef1234567890abcdef12345678"], }); ``` `subscribe(room, filters?)` returns a promise that resolves with the server's subscribe response (rejected filters, current configuration, and so on). Filters are fully typed per room. Some rooms have optional filters. For those, you can omit the second argument to subscribe without any filter: ```typescript theme={null} await ws.subscribe("polymarket_asset_prices"); await ws.subscribe("polymarket_clob_rewards", { subscribe_all: true }); await ws.subscribe("polymarket_markets_stream", { interval_ms: 1000, mode: "filter", filter: { categories: ["crypto"], min_volume: 50000 }, }); await ws.subscribe("polymarket_events_stream", { interval_ms: 500, mode: "ids", event_slugs: ["us-election-2028"], }); ``` Calling `subscribe` a second time on the same room replaces the previous filters. ## Listen for events Register handlers with `on(event, listener)`. Listeners receive fully typed payloads. `on` returns a disposer function that removes the listener. ```typescript theme={null} const disposeTrades = ws.on("trade_stream_update", (event) => { event.condition_id; event.price; event.size; event.side; }); ws.on("order_book_update", (event) => { event.asset_id; event.bids; event.asks; }); ws.on("clob_rewards_update", (event) => { event.event_type; event.condition_id; event.reward; }); ws.on("trader_global_pnl_batch", (event) => { event.block; event.timeframe; event.data; }); disposeTrades(); ``` You can also use `once` for one-shot listeners, `off` to remove a specific listener, and `removeAllListeners` to clear handlers for a given event (or all events). ## Unsubscribe and disconnect ```typescript theme={null} ws.unsubscribe("polymarket_trades"); ws.disconnect(); ``` `unsubscribe` leaves the room and drops its replay entry so it will not be resubscribed on reconnect. `disconnect` tears down the socket, cancels timers, and clears all state. ## Available rooms | Room | Filters | Event | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `polymarket_trades` | `condition_ids?`, `market_slugs?`, `event_slugs?`, `position_ids?`, `traders?`, `trade_types?`, `status?`, `subscribe_all?` | `trade_stream_update` | | `polymarket_oracle_events` | `condition_ids?`, `market_slugs?`, `event_slugs?`, `oracle_event_types?`, `status?`, `subscribe_all?` | `oracle_event_update` | | `polymarket_asset_prices` | `asset_symbols?` | `asset_price_tick`, `asset_price_window_update` | | `polymarket_asset_window_updates` | `asset_symbols?`, `timeframes?` | `asset_window_update` | | `polymarket_market_metrics` | `condition_ids` | `market_metrics_update` | | `polymarket_event_metrics` | `event_slugs` | `event_metrics_update` | | `polymarket_position_metrics` | `position_ids` | `position_metrics_update` | | `polymarket_tag_metrics` | `tags` | `tag_metrics_update` | | `polymarket_trader_pnl` | `traders`, `update_types?`, `timeframes?`, `dirty_kinds?` | `trader_global_pnl_batch`, `trader_market_pnl_batch`, `trader_category_pnl_batch`, `trader_global_tick_batch`, `trader_market_tick_batch`, `trader_category_tick_batch`, `trader_global_resolution_batch`, `trader_market_resolution_batch`, `trader_category_resolution_batch` | | `polymarket_trader_positions` | `traders`, `dirty_kinds?` | `trader_position_batch`, `trader_position_price_batch`, `trader_position_resolution_batch` | | `polymarket_trader_pnl_exits` | `traders`, `reasons?` | `trader_exit_marker_batch` | | `polymarket_holder_metrics` | `position_ids?`, `condition_ids?`, `event_slugs?` | `holder_metrics_position_batch`, `holder_metrics_condition_batch`, `holder_metrics_event_batch` | | `polymarket_accounts` | `wallets`, `include_usdce?`, `include_pusd?`, `include_matic?` | `accounts_update`, `usdce_update`, `pusd_update`, `matic_update` | | `polymarket_order_book` | `condition_ids?`, `position_ids?` | `order_book_update` | | `polymarket_clob_rewards` | `condition_ids?`, `subscribe_all?` | `clob_rewards_update` | | `polymarket_events_stream` | `interval_ms?`, `mode?`, `filter?`, `event_slugs?`, `event_ids?` | `events_stream_update` | | `polymarket_markets_stream` | `interval_ms?`, `mode?`, `filter?`, `condition_ids?`, `market_slugs?`, `event_slugs?` | `markets_stream_update` | | `polymarket_position_liquidity` | `position_ids?` | `position_liquidity_update` | | `polymarket_market_liquidity` | `condition_ids?` | `market_liquidity_update` | | `polymarket_event_liquidity` | `event_slugs?` | `event_liquidity_update` | See the [WebSockets tab](/websockets/getting-started) for full payload schemas for each room. ## Lifecycle events | Event | Payload | When it fires | | ------------------ | ------------------ | ------------------------------------------------------------------------- | | `connected` | `void` | Socket reaches the connected state (initial connect and every reconnect). | | `disconnected` | `{ code, reason }` | Connection closed, cleanly or otherwise. | | `reconnecting` | `{ attempt }` | Auto-reconnect attempt starting. | | `reconnect_failed` | `Error` | All reconnect attempts exhausted. | | `auth_failed` | `Error` | Server rejected the credentials. No further reconnects are attempted. | | `error` | `Error` | Socket or listener error. | | `warning` | `Error` | Non-fatal warning from the transport. | ```typescript theme={null} ws.on("connected", () => console.log("live")); ws.on("disconnected", ({ code, reason }) => console.log("gone", code, reason)); ws.on("reconnecting", ({ attempt }) => console.log("retry", attempt)); ws.on("auth_failed", (err) => console.error(err)); ``` ## Reconnection and replay When the socket drops, the SDK: 1. Emits `disconnected`. 2. Enters `reconnecting` state with exponential backoff and jitter. 3. On each attempt, rebuilds the URL via `getJwt` (if configured) so the latest JWT is used. 4. On successful reconnect, replays every active subscription so your handlers keep firing without manual bookkeeping. Configure behaviour via `reconnect`: ```typescript theme={null} const ws = new StructWebSocket({ apiKey: "sk_live_xxx", reconnect: { maxRetries: 10, initialDelayMs: 500, maxDelayMs: 30_000, }, subscribeTimeout: 10_000, }); ``` | Option | Default | Description | | -------------------------- | ---------- | ------------------------------------------------------------- | | `reconnect.maxRetries` | `Infinity` | Reconnect attempts before emitting `reconnect_failed`. | | `reconnect.initialDelayMs` | `1000` | Base delay between attempts. | | `reconnect.maxDelayMs` | `30_000` | Upper bound for the backoff delay. | | `subscribeTimeout` | `10_000` | Milliseconds to wait for each subscribe ack before rejecting. | ## Keepalive The transport sends a `ping` every 30 seconds and closes the socket if no `pong` arrives within 60. This is automatic; you do not need to call it. ## Connection state ```typescript theme={null} ws.state; ``` Possible values: `"disconnected"`, `"connecting"`, `"connected"`, `"reconnecting"`. # AI Agents Source: https://docs.struct.to/use-cases/ai-agents Power autonomous agents that research, monitor, and trade on prediction markets. ## Overview Prediction markets are a natural fit for AI agents. Structured outcomes, real-time pricing, and rich historical data make them ideal for automated research and trading. Struct provides the data infrastructure agents need to operate on Polymarket, and the docs are [built for agents](/introduction/ai-first) with copy-ready context on every page. ## What You Can Build Autonomous agents that monitor markets, evaluate probabilities, and execute strategies on the typed SDK. Agents that ingest market data and history to find movers and generate forecasts. Agents that watch for new markets, large trades, or price crossings and act on them. Bots that quote prices from the live order book and trade stream. ## Event-driven agent triggers Instead of polling, let events wake your agent up: * **[Price thresholds](/webhooks/price-threshold)**: trigger a workflow the moment a market crosses a level. Combine with [fire-and-delete](/webhooks/fire-and-delete) for single-shot triggers an agent arms and forgets. * **[Whale trades](/webhooks/trader-whale-trade)** and **[spikes](/webhooks/price-spike)**: react to large or fast-moving activity. * **Market lifecycle**: settle, re-evaluate, or open positions when a market [resolves](/webhooks/market-resolved) or is [disputed](/webhooks/market-disputed). Delivered as [webhooks](/webhooks/getting-started) or [websocket alerts](/websockets/alerts/getting-started), so an always-on agent can hold a socket while a serverless one takes an HTTP POST. ## Relevant Struct Features * **[REST API](/api-reference/getting-started)**: Give agents structured access to [markets, events, and trades](/guides/fetching-all-data), and [trader positions](/guides/trader-open-and-closed-positions). * **[Websockets](/websockets/getting-started)**: Feed agents real-time [price](/websockets/rooms/asset-prices) and [trade](/websockets/rooms/trades) streams for fast decisions. * **[Webhooks](/webhooks/getting-started)**: Trigger agent workflows when [specific events](/webhooks/price-threshold) occur, with no polling loop required. * **[TypeScript SDK](/sdk/overview)**: A typed client with [authentication](/sdk/authentication), [REST](/sdk/rest-api), and [streaming](/sdk/websockets) built in. * **[Indexing & Backfilling](/indexing/getting-started)**: Load historical data into your own database for backtesting and training. ## Guides to get started Rank the biggest moves across markets. Track what is heating up in real time. Notify on a price crossing via webhook. Common request recipes to get going fast. # Analytics & Research Source: https://docs.struct.to/use-cases/analytics-research Build dashboards, run quantitative research, and analyze prediction market data at scale. ## Overview Polymarket generates a wealth of structured data: market prices, trade volumes, trader behavior, and resolution outcomes. Struct makes this data accessible for analysts, researchers, and data teams who want to study prediction markets or build intelligence products on top of them. For large-scale work, [mirror the full dataset](/indexing/getting-started) into your own warehouse; for live views, pair the [REST API](/api-reference/getting-started) with [websocket rooms](/websockets/getting-started). ## What You Can Build Real-time and historical dashboards for liquidity, builder revenue, and trader PnL. Backtest forecasting models and study market efficiency against the full dataset. Detect market movers, track holder concentration, and surface emerging trends. Study accuracy and information aggregation with clean, complete data. ## Data at every granularity * **Metrics webhooks**: volume, fees, and transaction rollups at the [market](/webhooks/condition-metrics), [event](/webhooks/event-metrics), [position](/webhooks/position-metrics), and [tag](/webhooks/tag-metrics) level. * **[Holder metrics](/webhooks/condition-holder-metrics)**: track unique holders and concentration over time, walked through in the [holder metrics guide](/guides/holder-metrics-tracking). * **Resolutions**: capture terminal outcomes when markets [resolve](/webhooks/market-resolved) (and when they are [disputed](/webhooks/market-disputed)) to keep datasets current. * **Volume signals**: flag notable activity with [milestones](/webhooks/market-volume-milestone) and [spikes](/webhooks/market-volume-spike). ## Relevant Struct Features * **[REST API](/api-reference/getting-started)**: Query [markets and trades](/guides/fetching-all-data), candlesticks, and [trader PnL history](/guides/trader-pnl-history-charts), with [pagination](/api-reference/pagination) for large pulls. * **[Indexing & Backfilling](/indexing/getting-started)**: Load the complete Polymarket history into your own database for complex queries, joins, and aggregations. * **[Websockets](/websockets/getting-started)**: Power live dashboards with real-time [price](/websockets/rooms/asset-prices), [trade](/websockets/rooms/trades), and [metrics](/websockets/rooms/market-metrics) streams. * **[Webhooks](/webhooks/getting-started)**: Trigger data pipelines when markets [resolve](/webhooks/market-resolved) or [significant activity](/webhooks/market-volume-spike) occurs. ## Guides to get started Break down fee revenue by builder. Track liquidity across markets. Monitor holder counts and concentration. Page through the complete dataset. # Trading Platforms Source: https://docs.struct.to/use-cases/trading-platforms Build prediction market trading interfaces powered by real-time Polymarket data. ## Overview Whether you're building an aggregator, a copy-trading platform, or a fully custom trading UI, Struct gives you the data layer so you can focus on the experience. Most platforms combine the [REST API](/api-reference/getting-started) for on-demand data, [websocket rooms](/websockets/getting-started) for live streams, and [webhooks](/webhooks/getting-started) for event-driven alerts, all wrapped by the [TypeScript SDK](/sdk/overview). ## What You Can Build Build your own interface on top of Polymarket with full market data, order books, and TradingView charts. Combine Polymarket with other prediction markets for a unified view, with trending events and markets up front. Track top traders and mirror their pending or confirmed trades in real time. Show users their open positions, live PnL, and trade history. ## Discovery and trending Power homepage rails, "what's hot" widgets, and category browsers with rankings that re-order themselves in real time. * **Seed, then stream.** Pull initial lists from the [events](/api-reference/events/get-events) and [markets](/api-reference/market/list-markets) REST endpoints, then keep them warm with the [events stream](/websockets/rooms/events-stream) and [markets stream](/websockets/rooms/markets-stream) rooms, which push full rows for whatever changed since the last tick. The [live trending feed guide](/guides/real-time-trending-events-and-markets) walks through the whole loop. * **Rank by any window.** Every row carries a `metrics` map keyed by timeframe (`1m` through `30d`) with volume, fees, transactions, and unique traders, so one rail can sort by 24h volume while a "rising now" widget sorts by 5m trades. * **Search and filter.** Add [typeahead search](/guides/adding-search) and filter by category, rewards, or activity thresholds to build focused discover pages. * **Surface the movers.** Rank the biggest gainers and losers with the [finding market movers guide](/guides/finding-market-movers), and highlight markets paying [CLOB rewards](/websockets/rooms/clob-rewards) to draw liquidity providers. Both streams accept the same filters as their REST list counterparts, so a discover page and its live updates share a single definition. ## Crypto Up/Down markets Polymarket's Crypto Up/Down markets resolve over fixed windows: a window locks an open price when it starts and resolves UP or DOWN when it closes. They are a popular standalone trading surface, and Struct provides every layer to build one. * **Live spot and window state.** Stream sub-second prices from the [asset prices room](/websockets/rooms/asset-prices) and window open/close events from the [asset window updates room](/websockets/rooms/asset-window-updates) to show whether spot is above or below the current window's open. * **Resolution alerts.** Notify users the moment a window opens or closes with the [`asset_price_window_update`](/webhooks/asset-price-window-update) and [`asset_price_tick`](/webhooks/asset-price-tick) webhooks, also available as [alerts](/websockets/alerts/asset-price-window-update). The [Crypto Up/Down feed guide](/guides/crypto-up-down-feed) walks through the full build: backfilling past window outcomes, OHLC charts for the underlying asset, the live spot stream, and resolving each window. ## Leaderboards, holders, and live PnL Trader profiles, top-trader boards, and holder lists are first-class surfaces, and every figure is **computed live as trades settle, never refreshed on a periodic cycle or served stale from a cache**. A leaderboard re-ranks the moment a position changes, and a holder list reflects balances on the current block. * **Leaderboards and top traders.** Rank traders by realized PnL at [global](/api-reference/trader/get-global-pnl), [market](/api-reference/market/get-market-top-traders), [position](/api-reference/market/get-position-top-traders), or [category](/api-reference/tags/get-category-top-traders) scope, kept current as trades settle rather than on a refresh cycle. * **Live PnL.** Push [mark-to-market PnL](/introduction/concepts#pnl) over the [trader PnL room](/websockets/rooms/trader-pnl) at global, market, event, and category scope, and follow open positions through the [trader positions room](/websockets/rooms/trader-positions). The [live trader PnL dashboard](/guides/live-trader-pnl-dashboard) and [real-time positions](/guides/real-time-positions-tracking) guides show the full wiring. * **Top holders.** Live top-holder lists for any [market](/api-reference/holders/get-market-holders) or [position](/api-reference/holders/get-position-holders), plus holder-count time series, covered in the [holder metrics guide](/guides/holder-metrics-tracking) and pushed over the [holder metrics room](/websockets/rooms/holder-metrics). ## Real-time alerts for your users Let users follow the markets they care about and notify them the moment something happens: * **[Price thresholds](/webhooks/price-threshold)**: fire when a market crosses a price, ideal for [mobile push notifications](/guides/mobile-price-alerts). Add [fire-and-delete](/webhooks/fire-and-delete) for one-time alerts. * **Spikes**: surface fast [price moves](/webhooks/price-spike), scoped by tags or series. * **Bond zone**: catch outcomes that go [near-certain](/webhooks/close-to-bond) before the oracle settles, walked through in the [bond-zone alerts guide](/guides/bond-zone-alerts). * **Market lifecycle**: react when markets [resolve](/webhooks/market-resolved), get [disputed](/webhooks/market-disputed), or [launch](/webhooks/market-created). The same events arrive as [webhooks](/webhooks/getting-started) (HTTP) or [websocket alerts](/websockets/alerts/getting-started) (push over a live socket), so pick whichever transport fits your stack. ## Relevant Struct Features * **[REST API](/api-reference/getting-started)**: Fetch [markets, events, and trades](/guides/fetching-all-data), candlesticks, and [trader positions](/guides/trader-open-and-closed-positions) on demand, with [pagination](/api-reference/pagination) for large result sets. * **[Websockets](/websockets/getting-started)**: Stream real-time [trades](/websockets/rooms/trades), [order books](/websockets/rooms/order-book), and [prices](/websockets/rooms/asset-prices) directly to your frontend. * **[Webhooks](/webhooks/getting-started)**: Get notified when markets [resolve](/webhooks/market-resolved), [new markets launch](/webhooks/market-created), or [prices cross a threshold](/webhooks/price-threshold). * **[TypeScript SDK](/sdk/overview)**: A typed client for the [REST API](/sdk/rest-api), [websockets](/sdk/websockets), [alerts](/sdk/alerts), and [webhooks](/sdk/webhooks). * **[Indexing & Backfilling](/indexing/getting-started)**: Mirror the full Polymarket dataset into your own database for custom queries and fast lookups. ## Guides to get started Wire Polymarket candles into a TradingView chart. Keep a user's open positions live as trades confirm. Surface another trader's activity as it happens. Push a notification when a market crosses a price. # Asset Price Tick Source: https://docs.struct.to/webhooks/asset-price-tick Fires on every raw Chainlink price tick for a tracked crypto asset. **Event:** `asset_price_tick` \ **Cost:** 0.1 credits per delivery Fires on every raw Chainlink price tick for a tracked crypto asset. The full payload schema is in the auto-generated [Asset Price Tick callback](/api-reference/webhook-callbacks/asset-price-tick-callback) reference; this page documents the filters and matching behavior. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) builds on these asset price resolutions, including delivery over webhooks. ## When to use this * Stream live spot prices for specific assets into your own pricing engine. * Drive real-time price displays or alerting off the raw Chainlink feed. * Reconstruct fine-grained price history from individual ticks. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------- | --------- | ----------------------------------------------------------------------------- | | `asset_symbols` | string\[] | Restrict to crypto assets: `BTC`, `ETH`, `SOL`, `XRP`, `DOGE`, `BNB`, `HYPE`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "asset_price_tick", "filters": { "asset_symbols": ["BTC", "ETH"] } } ``` ## Notes This is a high-volume event — it fires on every price tick. Always filter to specific `asset_symbols` and expect a large number of deliveries. # Asset Price Window Update Source: https://docs.struct.to/webhooks/asset-price-window-update Fires twice per candle for a tracked asset — once when the window opens and once when it closes. **Event:** `asset_price_window_update` \ **Cost:** 0.1 credits per delivery Fires twice per candle for a tracked asset — once when the window opens and once when it closes. The full payload schema is in the auto-generated [Asset Price Window Update callback](/api-reference/webhook-callbacks/asset-price-window-update-callback) reference; this page documents the filters and matching behavior. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) builds on these window open and close events to resolve Up/Down outcomes. ## When to use this * Build live OHLC candles for specific assets and timeframes. * React the moment a candle opens or closes to update charts or indicators. * Feed windowed price data into trading signals or backtests. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------- | --------- | ----------------------------------------------------------------------------- | | `asset_symbols` | string\[] | Restrict to crypto assets: `BTC`, `ETH`, `SOL`, `XRP`, `DOGE`, `BNB`, `HYPE`. | | `timeframes` | string\[] | One or more candle sizes: `5m`, `15m`, `1h`, `4h`, `1d`, `24h`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "asset_price_window_update", "filters": { "asset_symbols": ["BTC", "ETH"], "timeframes": ["5m", "1h"] } } ``` ## Notes * This event fires twice per candle — once when the window opens and once when it closes — so expect two deliveries per candle for each subscribed timeframe. # Close to Bond Source: https://docs.struct.to/webhooks/close-to-bond Fire a webhook when a trade prints at a near-certain-outcome price. **Event:** `close_to_bond` \ **Cost:** 0.2 credits per delivery The `close_to_bond` callback fires when a trade occurs at the near-certain end of a market (for example an outcome trading at ≥ 95¢ or ≤ 5¢). The full callback schema is in the auto-generated [Close to bond callback](/api-reference/webhook-callbacks/close-to-bond-callback) reference; this page explains the one part the schema can't show on its own: how the price filters define the zone. ## Defining the bond zone The bond zone is defined entirely by how you combine `min_price` and `max_price`. The **relationship between the two values** selects one of four modes: | You set | Mode | Fires when | `bond_side` | | -------------------------- | ---------------- | ---------------------------------------------- | ------------------- | | `min_price` only | Single high edge | `price ≥ min_price` | `"high"` | | `max_price` only | Single low edge | `price ≤ max_price` | `"low"` | | Both, with **`min < max`** | Bounded range | `min_price ≤ price ≤ max_price` | `"high"` | | Both, with **`min > max`** | Two edges | `price ≥ min_price` **or** `price ≤ max_price` | `"high"` or `"low"` | **`min < max` is a band, not a bond.** If you set `min_price: 0.75` and `max_price: 0.90`, you do **not** get "fires above 90% or below 75%". Because `min < max`, it switches to bounded-range mode and fires only when the price lands **inside** the 75–90% band. To alert on the two near-certain extremes, you must set `min > max` (see below). ## Alerting on near-certain outcomes To get notified when an outcome becomes near-certain in **either** direction (at or above 90% **or** at or below 10%), set `min_price` **higher** than `max_price`: ```json theme={null} { "min_price": 0.90, "max_price": 0.10 } ``` Because `0.90 > 0.10`, this is read as two separate edges. A trade at 96¢ fires with `bond_side: "high"`; a trade at 4¢ fires with `bond_side: "low"`. For a single edge, set just one of the two. ## How price is read * The traded position's own `price` is used: the price of the exact outcome token (`position_id`) that printed. * Trades at a price of exactly `0` or `1` are skipped, since there is no remaining risk to alert on. * `price` is on a `0.0`–`1.0` scale (so 95¢ = `0.95`). ## Picking the right side On a binary market, "YES at ≤10%" and "NO at ≥90%" are the **same** event priced from opposite tokens. If you add `position_outcome_indices: [0]` you will only ever see trades that print on the **Yes/Up** token (index `0`); trades on the No token (index `1`) won't fire even when they hit the same bond zone. Omit `position_outcome_indices` to catch the zone regardless of which side the trade prints on. ## Subscription filters Add these to the `filters` object when you create the subscription. At least one of `min_price` or `max_price` is required. | Filter | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `min_price` | number | High-edge / lower bound of the zone, `0.0`–`1.0`. | | `max_price` | number | Low-edge / upper bound of the zone, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets (max 500). | | `position_ids` | string\[] | Restrict to specific outcome tokens (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `position_outcome_indices` | number\[] | Restrict by outcome index: `0` (Yes/Up) or `1` (No). | | `event_slugs` | string\[] | Restrict to specific events (max 500). | | `tags` | string\[] | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500). | | `series_slugs` | string\[] | Restrict to markets in any of these series by slug, case-insensitive (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. For a worked walkthrough across both webhooks and websockets, see the [Bond-zone alerts guide](/guides/bond-zone-alerts). # Condition Holder Metrics Source: https://docs.struct.to/webhooks/condition-holder-metrics Fire a webhook with holder counts for specific markets as they change. **Event:** `condition_holder_metrics` \ **Cost:** 0.025 credits per delivery Fires with the updated holder count for the markets (conditions) you subscribe to. The full payload schema is in the auto-generated [Condition Holder Metrics callback](/api-reference/webhook-callbacks/condition-holder-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track the number of distinct wallets holding any outcome in a market. * Watch participation grow or shrink across markets you care about. * Compare holder breadth between markets within an event. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Required | Description | | --------------- | --------- | -------- | ----------------------------------------------- | | `condition_ids` | string\[] | Yes | Condition IDs to track (at least one, max 500). | Unlike the optional filters on the PnL webhooks, `condition_ids` is required: a subscription must name at least one market to receive deliveries. ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "condition_holder_metrics", "filters": { "condition_ids": [ "0x1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef90" ] } } ``` ## Notes * The payload is a `ConditionHolderMetricsRow` carrying the condition ID and its holder count. * Each subscription accepts up to 500 condition IDs. # Condition Metrics Source: https://docs.struct.to/webhooks/condition-metrics Fire a webhook when a market's metrics cross your thresholds within a timeframe. **Event:** `condition_metrics` \ **Cost:** 0.1 credits per delivery The `condition_metrics` callback fires when a market's (condition's) metrics — volume, fees, transactions — cross your thresholds within a timeframe. The full payload schema is in the auto-generated [Condition Metrics callback](/api-reference/webhook-callbacks/market-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Surface markets whose volume or fee accrual spikes past a threshold inside a rolling window. * Track transaction count or unique-trader growth on a specific set of markets. * Power "trending market" alerts driven by short-window activity rather than lifetime totals. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | -------------------- | --------- | ----------------------------------------------------------------------------------- | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_fees` | number | Minimum fees accrued in USD. | | `min_txns` | integer | Minimum transaction count. | | `min_unique_traders` | integer | Minimum number of unique traders. | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "condition_metrics", "filters": { "min_volume_usd": 50000, "min_unique_traders": 25, "timeframes": ["1h", "24h"] } } ``` ## Notes * `timeframes` selects which rolling windows are evaluated; the event fires when the thresholds are met in any listed window. * Omitting `condition_ids` evaluates the thresholds across all markets. # Instant Delivery Source: https://docs.struct.to/webhooks/delivery Struct evaluates and delivers webhooks the moment a condition is met, not on a polling cycle. Struct webhooks are **event-driven**: conditions are evaluated inline as on-chain activity is ingested, and a delivery fires the instant they are met. No polling loop sits between the event and your endpoint. Conditions are checked as each on-chain event lands, so the webhook goes out the moment a threshold is crossed. No polling interval delaying it. No per-subscription polling cost means you can run **hundreds of thousands** of active webhooks, where polling stacks top out at a few hundred. ## How it compares | | Polling (cron) | Struct (event-driven) | | ----------------------------- | -------------------------- | ------------------------------- | | **When it fires** | Next poll after the event | The moment the condition is met | | **Freshness** | Up to 30s behind real time | No polling delay | | **Active webhooks supported** | Hundreds | Hundreds of thousands | ## Why polling falls short Many webhook providers run a scheduled job (a cron) that periodically re-queries the data and emits a webhook if the query returns something new. That design carries two costs: * **A latency floor.** A delivery is only ever as fresh as the polling interval. A job that runs every 30 seconds can leave you 30 seconds behind real time even when the event happened immediately. * **A scale ceiling.** Every subscription adds another query to every cycle, so work grows with subscriptions multiplied by frequency. Polling stacks top out at a few hundred active webhooks before the job can no longer keep up. Struct evaluates conditions inline with ingestion instead of re-querying on a timer, so neither limit applies. The same value Struct already updates on every on-chain event (PnL, volume, metrics, probabilities) is checked against your webhook conditions in the same path, and the delivery is sent the moment they match. ## What this means for you * Set thresholds as tight as you like; each delivery reflects the exact moment the condition is crossed. * Run a webhook per trader, per market, or per user, and scale to as many as your plan allows. See [Limits](/webhooks/getting-started#limits) for per-plan counts. * Power time-sensitive automations (copy-trading, liquidation triggers, alerts) that act on the event itself, the instant it happens. # Event Holder Metrics Source: https://docs.struct.to/webhooks/event-holder-metrics Fire a webhook with holder counts for specific events as they change. **Event:** `event_holder_metrics` \ **Cost:** 0.025 credits per delivery Fires with the updated holder count for the events you subscribe to. The full payload schema is in the auto-generated [Event Holder Metrics callback](/api-reference/webhook-callbacks/event-holder-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track the number of distinct wallets participating in an event across all its markets. * Watch event-level participation grow or shrink over time. * Compare holder breadth between events you follow. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Required | Description | | ------------- | --------- | -------- | --------------------------------------------- | | `event_slugs` | string\[] | Yes | Event slugs to track (at least one, max 500). | Unlike the optional filters on the PnL webhooks, `event_slugs` is required: a subscription must name at least one event to receive deliveries. ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "event_holder_metrics", "filters": { "event_slugs": ["us-presidential-election-2028"] } } ``` ## Notes * The payload is an `EventHolderMetricsRow` carrying the event slug and its holder count. * Each subscription accepts up to 500 event slugs. # Event Metrics Source: https://docs.struct.to/webhooks/event-metrics Fire a webhook when an event's aggregated metrics cross your thresholds within a timeframe. **Event:** `event_metrics` \ **Cost:** 0.1 credits per delivery The `event_metrics` callback fires when an event's aggregated metrics cross your thresholds within a timeframe. The full payload schema is in the auto-generated [Event Metrics callback](/api-reference/webhook-callbacks/event-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Detect when total volume across all markets in an event crosses a threshold inside a rolling window. * Track fee accrual or transaction count at the event level rather than per market. * Drive "hot event" alerts while excluding noisy short-term Up/Down markets. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ----------------------------------------------------------------------------------- | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_fees` | number | Minimum fees accrued in USD. | | `min_txns` | integer | Minimum transaction count. | | `min_unique_traders` | integer | Minimum number of unique traders. | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "event_metrics", "filters": { "min_volume_usd": 250000, "timeframes": ["24h"], "exclude_shortterm_markets": true } } ``` ## Notes * `timeframes` selects which rolling windows are evaluated; the event fires when the thresholds are met in any listed window. * Set `exclude_shortterm_markets` to drop high-churn Up/Down markets from the aggregation. # Event Volume Milestone Source: https://docs.struct.to/webhooks/event-volume-milestone Fire a webhook when an event's aggregated volume crosses a USD milestone. **Event:** `event_volume_milestone` \ **Cost:** 0.2 credits per delivery Fires when an event's aggregated cumulative volume crosses one of your USD milestones. The full payload schema is in the auto-generated [Event Volume Milestone callback](/api-reference/webhook-callbacks/event-volume-milestone-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track when an entire event reaches a notable volume threshold across all its markets. * Spot trending events by watching aggregated volume cross $100K or $1M. * Trigger alerts or workflows when an event hits a liquidity milestone within a chosen window. ## Subscription filters Add these to the `filters` object when you create the subscription. `timeframes` is required. | Filter | Type | Description | | --------------------------- | ---------- | ----------------------------------------------------------------------------------- | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | | `milestone_amounts` | integer\[] | USD milestone thresholds to fire on (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "event_volume_milestone", "filters": { "timeframes": ["24h"], "milestone_amounts": [100000, 1000000], "exclude_shortterm_markets": true } } ``` ## Notes * `milestone_amounts` are USD thresholds. Each amount fires once when the aggregated cumulative volume crosses it. # Event Volume Spike Source: https://docs.struct.to/webhooks/event-volume-spike Fire a webhook when an event's aggregated volume surges past its recent baseline. **Event:** `event_volume_spike` \ **Cost:** 0.2 credits per delivery The `event_volume_spike` event fires when an event's aggregated volume in a timeframe exceeds its recent baseline by your spike ratio. The full payload schema is in the auto-generated [Event Volume Spike callback](/api-reference/webhook-callbacks/event-volume-spike-callback) reference; this page documents the filters and matching behavior. ## When to use this * Detect activity surges across all markets in an event at once. * Tune sensitivity by setting how far aggregated volume must exceed its baseline. * Compare event-level activity across multiple timeframes in a single subscription. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ----------------------------------------------------------------------------------------- | | `spike_ratio` | number | Volume must exceed its baseline by at least this ratio (must be greater than `1.0`). | | `window_secs` | integer | Look-back window in seconds (`1`–`600`). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `1d`, `24h`, `7d`, `30d`, `lifetime`. | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "event_volume_spike", "filters": { "spike_ratio": 2.5, "window_secs": 600, "timeframes": ["1h"] } } ``` ## Notes * `spike_ratio` must be greater than `1.0`; for example `3.0` means 3x the recent baseline. # Fire-and-Delete Source: https://docs.struct.to/webhooks/fire-and-delete Auto-delete a webhook subscription after its first successful delivery with one_shot. **Filter:** `one_shot` (boolean) \ **Applies to:** every webhook event Set `one_shot: true` in a webhook's `filters` to make it a one-time trigger: the subscription delivers once, then deletes itself. It is the webhook equivalent of a single-use alert, useful when you only care about the *first* time something happens and do not want to manage cleanup yourself. ## How it works When a delivery for a `one_shot` webhook succeeds, Struct deletes the subscription automatically. No further events are delivered, and the webhook no longer appears in [`GET /v1/webhooks`](/webhooks/getting-started#managing-webhooks). * **Fires exactly once.** If several events match in the same instant, only the first delivery goes out; the rest are dropped before the subscription is removed. * **Deletes only on success.** The subscription is removed after a delivery your endpoint acknowledges with a `2xx`. If every retry fails, the webhook is not deleted: it follows the normal [retry and auto-disable](/webhooks/delivery) path instead, so a one-shot is never silently lost to a flaky endpoint. * **Test deliveries are safe.** A [test delivery](/webhooks/getting-started) never consumes the one-shot or deletes the subscription, so you can verify your endpoint as many times as you like before the real event arrives. ## Enabling it Add `one_shot` to the `filters` object alongside any other filters for the event. The best fit is [`price_threshold`](/webhooks/price-threshold): watch a market and fire the first time its price crosses your target, then remove the subscription. Here it fires once when a market's YES outcome crosses up through 75%: ```typescript SDK theme={null} import { StructClient } from "@structbuild/sdk"; const client = new StructClient({ apiKey: "sk_live_xxx" }); const { data: webhook } = await client.webhooks.create({ url: "https://your-server.com/webhooks", event: "price_threshold", filters: { condition_ids: ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], min_price: 0.75, one_shot: true, }, }); ``` ```bash cURL theme={null} curl -X POST https://api.struct.to/v1/webhooks \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks", "event": "price_threshold", "filters": { "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_price": 0.75, "one_shot": true } }' ``` `price_threshold` requires `position_ids` or `condition_ids` when `one_shot` is set, so the subscription targets a bounded set of markets. The `condition_ids` above satisfies that. ## Common patterns * **First-of-a-kind events.** Beyond `price_threshold`, catch the first whale trade on a market, the first new market in a category, or a single resolution, without leaving a long-lived subscription behind. * **Self-cleaning automations.** Spin up a webhook for a transient interest (a market you are watching today) and let it remove itself once it fires. ## Notes * `one_shot` is available on every event's filters and defaults to `false`. * The deletion is recorded with the event that triggered it, so you can trace why a subscription disappeared. * [`price_threshold`](/webhooks/price-threshold) additionally requires `position_ids` or `condition_ids` when `one_shot` is set, so the target stays bounded. # Getting Started Source: https://docs.struct.to/webhooks/getting-started Receive real-time notifications when events happen on Polymarket via webhooks. ## Overview Webhooks let you subscribe to real-time events from Polymarket (like trades, PnL changes, volume milestones, and price spikes) delivered directly to your server as HTTP POST requests. Instead of polling the API, webhooks push data to you the moment something happens. Deliveries are [event-driven](/webhooks/delivery), evaluated inline as on-chain activity is ingested rather than on a polling cycle. ## Base URL All webhook management requests use the same base URL as the REST API: ``` https://api.struct.to ``` ## Setting Up Your First Webhook Sign up at [struct.to/dashboard](https://struct.to/dashboard) and create an organisation. Navigate to the API Keys section in your dashboard and create a new key. Set up an HTTP endpoint on your server that accepts POST requests and returns a `200` status code. Your endpoint must be publicly accessible. Register your endpoint with the event you want to listen to. Each subscription targets a single event with its own filters — create one subscription per event: ```bash cURL theme={null} curl -X POST https://api.struct.to/v1/webhooks \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks", "event": "trader_whale_trade", "secret": "your_signing_secret", "filters": { "min_usd_value": 10000 } }' ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.struct.to/v1/webhooks", { method: "POST", headers: { "X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://your-server.com/webhooks", event: "trader_whale_trade", secret: "your_signing_secret", filters: { min_usd_value: 10000 }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.post( "https://api.struct.to/v1/webhooks", headers={"X-API-Key": "YOUR_API_KEY"}, json={ "url": "https://your-server.com/webhooks", "event": "trader_whale_trade", "secret": "your_signing_secret", "filters": {"min_usd_value": 10000}, }, ) data = response.json() ``` Send a test payload to verify connectivity: ```bash theme={null} curl -X POST https://api.struct.to/v1/webhooks/{webhook_id}/test \ -H "X-API-Key: YOUR_API_KEY" ``` ## Available Events ### Trader Events | Event | Description | | -------------------- | ---------------------------------------------------------------------------------------- | | `trader_first_trade` | Tracked trader executes their first-ever trade on Polymarket | | `trader_new_market` | Trader places their first trade in a specific market (fires once per trader+market pair) | | `trader_new_trade` | Every order-filled trade by a tracked trader | | `trader_trade_event` | Typed prediction-trade stream events for a tracked trader | | `trader_whale_trade` | Trade exceeds the configured size and probability thresholds | | `trader_global_pnl` | Trader's global PnL (across all markets) crosses a configured threshold | | `trader_market_pnl` | Trader's per-market PnL crosses a configured threshold | | `trader_event_pnl` | Trader's per-event PnL crosses a configured threshold | ### Market & Event Metrics | Event | Description | | ------------------- | ----------------------------------------------------------------------------- | | `condition_metrics` | Market's volume or transaction metrics cross a configured threshold | | `event_metrics` | Event's aggregated volume or transaction metrics cross a configured threshold | | `position_metrics` | Position's volume or transaction metrics cross a configured threshold | | `tag_metrics` | Tag's aggregated volume or transaction metrics cross a configured threshold | ### Volume Milestones | Event | Description | | --------------------------- | --------------------------------------------------------- | | `market_volume_milestone` | Market's trading volume crosses a USD milestone | | `event_volume_milestone` | Event's aggregated trading volume crosses a USD milestone | | `position_volume_milestone` | Position's trading volume crosses a USD milestone | ### Spikes | Event | Description | | ----------------------- | ------------------------------------------------------------------------------------------- | | `price_spike` | Position's raw trade price (not enriched probability) spikes within a window | | `price_threshold` | Outcome's price crosses a target level you set (edge-triggered, fires once per crossing) | | `market_volume_spike` | Market's volume in a timeframe exceeds the configured baseline by the spike ratio | | `event_volume_spike` | Event's aggregated volume in a timeframe exceeds the configured baseline by the spike ratio | | `position_volume_spike` | Position's volume in a timeframe exceeds the configured baseline by the spike ratio | ### Market Lifecycle | Event | Description | | ----------------- | ------------------------------------------------------------------------------------------- | | `close_to_bond` | Trade occurs at a near-certain-outcome price | | `market_created` | New prediction market is detected on-chain and enriched with metadata | | `oracle_events` | On-chain oracle activity: resolution, dispute, assertion, emergency, and related events | | `market_resolved` | Market reaches a terminal resolution, deduplicated to one delivery with the winning outcome | | `market_disputed` | Proposed market resolution is challenged on-chain | ### Asset Prices | Event | Description | | --------------------------- | ---------------------------------------------------------- | | `asset_price_tick` | Every raw Chainlink price tick for a tracked crypto asset | | `asset_price_window_update` | Delivered twice per candle: once on open and once on close | ## Filtering Webhooks support granular filters so you only receive the events you care about. Filters can be passed when creating or updating a webhook: * **Wallet address**: track specific traders * **Market / condition ID**: target specific markets * **Event slug**: filter by event * **USD thresholds**: minimum/maximum value bounds * **Probability range**: 0.0 to 1.0 * **PnL bounds and volume minimums** Every event also accepts `one_shot` to fire once and then delete the subscription automatically. See [Fire-and-Delete](/webhooks/fire-and-delete). ## Verifying Payloads If you provide a `secret` when creating your webhook, each delivery includes an `X-Webhook-Signature` header (HMAC-SHA256 of the raw body) you can use to verify the payload originated from Struct. Always verify webhook signatures in production to prevent spoofed requests. See [Signature Verification](/webhooks/signature-verification) for the scheme and copy-paste verification code. ## Managing Webhooks You can list, update, pause, and delete your webhooks via the API: | Method | Endpoint | Description | | -------- | --------------------------------- | -------------------------------------------------- | | `GET` | `/v1/webhooks` | List all your webhook subscriptions | | `GET` | `/v1/webhooks/events` | List available event types | | `GET` | `/v1/webhooks/{id}` | Get a specific webhook | | `PUT` | `/v1/webhooks/{id}` | Update a webhook's URL, events, filters, or status | | `DELETE` | `/v1/webhooks/{id}` | Delete a webhook | | `POST` | `/v1/webhooks/{id}/rotate-secret` | Rotate the signing secret | | `POST` | `/v1/webhooks/{id}/test` | Send a test payload | | `GET` | `/v1/webhooks/{id}/logs` | Inspect recent delivery logs (7-day retention) | ## Limits How many webhook subscriptions you can create depends on your plan: **10** on Free, **5,000** on Hobby, **25,000** on Startup, and **100,000** on Scale (Enterprise is unlimited). Rate limits on the management API also depend on your plan; see [Pricing & Ratelimits](/introduction/pricing) for details. If you need higher limits, reach out to us at [support@struct.to](mailto:support@struct.to). # Market Created Source: https://docs.struct.to/webhooks/market-created Fires when a new prediction market is detected on-chain and enriched with metadata. **Event:** `market_created` \ **Cost:** 0.1 credits per delivery Fires when a new prediction market is detected on-chain and enriched with metadata. The full payload schema is in the auto-generated [Market Created callback](/api-reference/webhook-callbacks/market-created-callback) reference; this page documents the filters and matching behavior. ## When to use this * Auto-list newly created markets in your app the moment they appear. * Trigger curation or moderation workflows for markets matching specific tags. * Seed analytics or indexing pipelines as soon as a market is enriched. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `tags` | string\[] | Restrict by tag or category, given as the display label shown on Polymarket (e.g. `"Sports"`, `"FIFA World Cup"`), not a slug. Case-insensitive (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "market_created", "filters": { "tags": ["Politics", "Crypto"], "exclude_shortterm_markets": true } } ``` # Market Disputed Source: https://docs.struct.to/webhooks/market-disputed Fire a webhook when a proposed market resolution is challenged on-chain. **Event:** `market_disputed` \ **Cost:** 0.1 credits per delivery The `market_disputed` event fires when a proposed market resolution is challenged on-chain (`DisputePrice` or `AssertionDisputed`). The resolution is now contested and delayed. The payload carries the disputer, the proposer, and the disputed or proposed outcome when available. The full payload schema is in the auto-generated [Market Disputed callback](/api-reference/webhook-callbacks/market-disputed-callback) reference; this page documents the filters and matching behavior. ## When to use this * Flag markets whose resolution is contested so you can hold settlement until it clears. * Alert users that an outcome they hold is under dispute. * Track dispute activity for risk or moderation workflows. ## Subscription filters Add these to the `filters` object when you create the subscription. All filters are optional. | Filter | Type | Description | | --------------------------- | --------- | ------------------------------------------------------- | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "market_disputed", "filters": { "exclude_shortterm_markets": true } } ``` ## Notes * `dispute_kind` reports which oracle event produced the dispute (`dispute_price` or `assertion_disputed`). * `proposed_price` is present only for `DisputePrice` disputes (`0.0`–`1.0`). * Pair this with [`market_resolved`](/webhooks/market-resolved) to follow a market from contested back to final. # Market Resolved Source: https://docs.struct.to/webhooks/market-resolved Fire a webhook once when a market reaches a terminal resolution. **Event:** `market_resolved` \ **Cost:** 0.1 credits per delivery The `market_resolved` event fires once when a market reaches a terminal resolution. It collapses the underlying oracle events (`QuestionResolved`, `QuestionEmergencyResolved`, `ConditionResolution`, `NegRiskOutcomeReported`) into a single notification carrying the winning outcome and, when available, the settled price. The full payload schema is in the auto-generated [Market Resolved callback](/api-reference/webhook-callbacks/market-resolved-callback) reference; this page documents the filters and matching behavior. ## When to use this * Settle positions and update balances the moment a market resolves, without parsing raw oracle events. * Notify users of the final outcome on markets they follow. * Trigger payout, accounting, or archival workflows on resolution. ## Why not oracle\_events? [`oracle_events`](/webhooks/oracle-events) delivers every raw on-chain oracle action, and a single resolution can emit several of them in a burst. `market_resolved` projects that burst into one clean, deduplicated delivery per market with the winning outcome already resolved, so you do not have to reconcile the lifecycle yourself. ## Subscription filters Add these to the `filters` object when you create the subscription. All filters are optional. | Filter | Type | Description | | --------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `outcomes` | string\[] | Only fire when the winning outcome matches one of these, e.g. `["Yes"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "market_resolved", "filters": { "event_slugs": ["us-presidential-election-2028"] } } ``` ## Notes * Fires once per market. The resolution burst is deduplicated, so you receive a single delivery even though several oracle events back it. * `resolution_kind` reports which oracle event produced the resolution (`question_resolved`, `question_emergency_resolved`, `condition_resolution`, or `neg_risk_outcome_reported`). * `settled_price` is present only when the resolution event carries one (`QuestionResolved`): `1.0` = full YES, `0.0` = full NO, `0.5` = split. # Market Volume Milestone Source: https://docs.struct.to/webhooks/market-volume-milestone Fire a webhook when a market's cumulative volume crosses a USD milestone. **Event:** `market_volume_milestone` \ **Cost:** 0.2 credits per delivery Fires when a market's cumulative volume crosses one of your USD milestones. The full payload schema is in the auto-generated [Market Volume Milestone callback](/api-reference/webhook-callbacks/market-volume-milestone-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track when a specific market reaches a notable volume threshold like $100K or $1M. * Surface markets gaining traction within a chosen timeframe for trading or content. * Trigger downstream alerts or workflows the moment a market hits a liquidity milestone. ## Subscription filters Add these to the `filters` object when you create the subscription. `timeframes` is required. | Filter | Type | Description | | ------------------- | ---------- | ----------------------------------------------------------------------------------- | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | | `milestone_amounts` | integer\[] | USD milestone thresholds to fire on (max 500). | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "market_volume_milestone", "filters": { "timeframes": ["24h"], "milestone_amounts": [100000, 1000000] } } ``` ## Notes * `milestone_amounts` are USD thresholds. Each amount fires once when the cumulative volume crosses it. # Market Volume Spike Source: https://docs.struct.to/webhooks/market-volume-spike Fire a webhook when a market's volume surges past its recent baseline. **Event:** `market_volume_spike` \ **Cost:** 0.2 credits per delivery The `market_volume_spike` event fires when a market's volume in a timeframe exceeds its recent baseline by your spike ratio. The full payload schema is in the auto-generated [Market Volume Spike callback](/api-reference/webhook-callbacks/market-volume-spike-callback) reference; this page documents the filters and matching behavior. ## When to use this * Detect unusual activity surges on specific markets. * Tune sensitivity by setting how far volume must exceed its baseline. * Compare activity across multiple timeframes in a single subscription. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------- | --------- | ----------------------------------------------------------------------------------------- | | `spike_ratio` | number | Volume must exceed its baseline by at least this ratio (must be greater than `1.0`). | | `window_secs` | integer | Look-back window in seconds (`1`–`600`). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `1d`, `24h`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "market_volume_spike", "filters": { "spike_ratio": 3.0, "window_secs": 300, "timeframes": ["1h"] } } ``` ## Notes * `spike_ratio` must be greater than `1.0`; for example `3.0` means 3x the recent baseline. # Oracle Events Source: https://docs.struct.to/webhooks/oracle-events Fires on on-chain oracle activity — resolution, dispute, assertion, emergency, and related event types. **Event:** `oracle_events` \ **Cost:** 0.1 credits per delivery Fires on on-chain oracle activity — resolution, dispute, assertion, emergency, and related event types. The full payload schema is in the auto-generated [Oracle Events callback](/api-reference/webhook-callbacks/oracle-events-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track market resolutions and settle positions as soon as the oracle reports. * Get alerted to disputes or emergency actions that may affect open markets. * Monitor assertion lifecycle activity for specific conditions you care about. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | -------------------- | --------- | ----------------------------------------------------------------------------------------------------------- | | `oracle_event_types` | string\[] | Restrict to specific oracle event types, e.g. `QuestionResolved`, `DisputePrice`, `ProposePrice` (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "oracle_events", "filters": { "oracle_event_types": ["QuestionResolved", "DisputePrice"] } } ``` ## Notes * `oracle_event_types` narrows delivery to specific lifecycle events — set it to receive only the stages you care about (e.g. just `QuestionResolved`) instead of every oracle action. # Position Holder Metrics Source: https://docs.struct.to/webhooks/position-holder-metrics Fire a webhook with holder counts and balances for specific positions as they change. **Event:** `position_holder_metrics` \ **Cost:** 0.025 credits per delivery Fires with updated holder metrics for the positions you subscribe to, including holder count, total balance, and total cost basis. The full payload schema is in the auto-generated [Position Holder Metrics callback](/api-reference/webhook-callbacks/position-holder-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track how many wallets hold a specific outcome token over time. * Watch total balance and cost basis concentration on positions you care about. * Detect accumulation or distribution as holder counts move on key positions. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Required | Description | | -------------- | --------- | -------- | ---------------------------------------------------- | | `position_ids` | string\[] | Yes | Position token IDs to track (at least one, max 500). | Unlike the optional filters on the PnL webhooks, `position_ids` is required: a subscription must name at least one position to receive deliveries. ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "position_holder_metrics", "filters": { "position_ids": [ "71321045679252212594626385532706912750332728571942532289631379312455583992563" ] } } ``` ## Notes * The payload is a `PositionHolderMetricsRow` with holder count, total balance, and total cost basis, plus the owning condition and event holder counts when available. * Each subscription accepts up to 500 position IDs. # Position Metrics Source: https://docs.struct.to/webhooks/position-metrics Fire a webhook when a position's metrics cross your thresholds within a timeframe. **Event:** `position_metrics` \ **Cost:** 0.1 credits per delivery The `position_metrics` callback fires when a position's (outcome token's) metrics cross your thresholds within a timeframe. The full payload schema is in the auto-generated [Position Metrics callback](/api-reference/webhook-callbacks/position-metrics-callback) reference; this page documents the filters and matching behavior. **`price_*` and `probability_*` are different series.** `price_*` tracks the outcome token you're looking at (filter: `min_price_change_pct`); `probability_*` always tracks the Yes/Up token, index `0` (filter: `min_probability_change_pct`). They agree on the Yes side; on the No side they are complements (a `price` of `0.40` is a `probability` of `0.60`). ## When to use this * Catch outcome tokens with surging buy- or sell-side volume inside a rolling window. * Alert on sharp price or probability moves on specific positions. * Monitor fee accrual, transaction count, or unique-trader growth at the outcome-token level. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | ---------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `position_ids` | string\[] | Restrict to specific outcome tokens by position ID (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_buy_usd` | number | Minimum buy-side volume in USD. | | `min_sell_volume_usd` | number | Minimum sell-side volume in USD. | | `min_fees` | number | Minimum fees accrued in USD. | | `min_txns` | integer | Minimum transaction count. | | `min_unique_traders` | integer | Minimum number of unique traders. | | `min_price_change_pct` | number | Minimum price change, as a percentage. | | `min_probability_change_pct` | number | Minimum probability change, as a percentage. | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "position_metrics", "filters": { "outcomes": ["Yes"], "min_buy_usd": 10000, "min_price_change_pct": 5, "timeframes": ["1h"] } } ``` ## Notes * `timeframes` selects which rolling windows are evaluated; the event fires when the thresholds are met in any listed window. * `min_price_change_pct` and `min_probability_change_pct` are measured over the evaluated window. # Position Volume Milestone Source: https://docs.struct.to/webhooks/position-volume-milestone Fire a webhook when a position's cumulative volume crosses a USD milestone. **Event:** `position_volume_milestone` \ **Cost:** 0.2 credits per delivery Fires when a position's cumulative volume crosses one of your USD milestones. The full payload schema is in the auto-generated [Position Volume Milestone callback](/api-reference/webhook-callbacks/position-volume-milestone-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track when a specific outcome token reaches a notable volume threshold. * Watch a single side of a market (e.g. `Yes`) cross $100K or $1M in volume. * Trigger alerts or workflows when a position hits a liquidity milestone within a chosen window. ## Subscription filters Add these to the `filters` object when you create the subscription. `timeframes` is required. | Filter | Type | Description | | ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `position_ids` | string\[] | Restrict to specific outcome tokens by position ID (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | | `milestone_amounts` | integer\[] | USD milestone thresholds to fire on (max 500). | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "position_volume_milestone", "filters": { "outcomes": ["Yes"], "timeframes": ["24h"], "milestone_amounts": [100000, 1000000] } } ``` ## Notes * `milestone_amounts` are USD thresholds. Each amount fires once when the position's cumulative volume crosses it. # Position Volume Spike Source: https://docs.struct.to/webhooks/position-volume-spike Fire a webhook when a position's volume surges past its recent baseline. **Event:** `position_volume_spike` \ **Cost:** 0.2 credits per delivery The `position_volume_spike` event fires when a position's volume in a timeframe exceeds its recent baseline by your spike ratio. The full payload schema is in the auto-generated [Position Volume Spike callback](/api-reference/webhook-callbacks/position-volume-spike-callback) reference; this page documents the filters and matching behavior. ## When to use this * Detect activity surges on a single outcome token rather than a whole market. * Tune sensitivity by setting how far volume must exceed its baseline. * Scope alerts to specific outcomes or markets you track. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spike_ratio` | number | Volume must exceed its baseline by at least this ratio (must be greater than `1.0`). | | `window_secs` | integer | Look-back window in seconds (`1`–`600`). | | `position_ids` | string\[] | Restrict to specific outcome tokens by position ID (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `1d`, `24h`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "position_volume_spike", "filters": { "spike_ratio": 4.0, "window_secs": 180, "outcomes": ["Yes"], "timeframes": ["1h"] } } ``` ## Notes * `spike_ratio` must be greater than `1.0`; for example `3.0` means 3x the recent baseline. # Price Spike Source: https://docs.struct.to/webhooks/price-spike Fire a webhook when a position's raw trade price moves sharply within a look-back window. **Event:** `price_spike` \ **Cost:** 0.2 credits per delivery The `price_spike` event fires when a position's raw trade price (not enriched probability) changes by at least your configured percentage within a look-back window. The full payload schema is in the auto-generated [Price Spike callback](/api-reference/webhook-callbacks/price-spike-callback) reference; this page documents the filters and matching behavior. ## When to use this * Track abrupt moves in the underlying trade price rather than the enriched probability. * Alert on price swings only within a probability band you care about. * Filter out thin moves by requiring minimum transaction and volume activity. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `position_ids` | string\[] | Restrict to specific outcome tokens by position ID (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `tags` | string\[] | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500). | | `series_slugs` | string\[] | Restrict to markets in any of these series by slug, case-insensitive (max 500). | | `min_price_change_pct` | number | Minimum price change, as a percentage. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `min_txns` | integer | Minimum transaction count. | | `min_volume_usd` | number | Minimum traded volume in USD. | | `spike_direction` | string | Direction to match: `up`, `down`, or `both`. | | `window_secs` | integer | Look-back window in seconds (`1`–`600`). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "price_spike", "filters": { "min_price_change_pct": 8, "spike_direction": "up", "window_secs": 120, "min_txns": 5 } } ``` ## Notes * `window_secs` sets the look-back window (`1`–`600` seconds) over which the change is measured. * `spike_direction` controls which moves match: `up`, `down`, or `both`. Defaults to `up` when omitted. # Price Threshold Source: https://docs.struct.to/webhooks/price-threshold Fire a webhook when an outcome's price crosses a target level. **Event:** `price_threshold` \ **Cost:** 0.2 credits per delivery The `price_threshold` event fires when an outcome's price (implied probability) crosses a target level you set. It is edge-triggered: it fires once on the crossing, not on every trade that stays past the level. The full payload schema is in the auto-generated [Price Threshold callback](/api-reference/webhook-callbacks/price-threshold-callback) reference; this page documents the filters and matching behavior. **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. ## When to use this * Alert the moment an outcome crosses up through a level (e.g. YES reaches 75%) or down through one (e.g. YES falls to 25%). * Build one-time triggers that delete themselves after firing, with [`one_shot`](/webhooks/fire-and-delete). * Catch positions that are already past your target when you subscribe, with `fire_if_already_past`. ## Defining the threshold Set `min_price` for an **upward** target (fire when the price crosses up to ≥ that value) and `max_price` for a **downward** target (fire when the price crosses down to ≤ that value). At least one is required; set both to watch a position from either side. By default the event waits for a real crossing: it needs a prior observation on the other side of the target before it fires. Set `fire_if_already_past` to fire immediately when the first observed price is already past the target. ## Subscription filters Add these to the `filters` object when you create the subscription. At least one of `min_price` or `max_price` is required. | Filter | Type | Description | | --------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `min_price` | number | Upward target, `0.0`–`1.0`. Fire when the price crosses up to ≥ this value (e.g. `0.75`). | | `max_price` | number | Downward target, `0.0`–`1.0`. Fire when the price crosses down to ≤ this value (e.g. `0.25`). | | `one_shot` | boolean | Delete the subscription after its first delivery ([fire-and-delete](/webhooks/fire-and-delete)). Requires `position_ids` or `condition_ids`. Defaults to `false`. | | `fire_if_already_past` | boolean | Fire immediately if the first observed price is already past the target, with no prior baseline. Defaults to `false`. | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `position_ids` | string\[] | Restrict to specific outcome tokens by position ID (max 500). | | `outcomes` | string\[] | Restrict by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `position_outcome_indices` | number\[] | Restrict by outcome index: `0` (Yes/Up) or `1` (No). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `tags` | string\[] | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500). | | `series_slugs` | string\[] | Restrict to markets in any of these series by slug, case-insensitive (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. ## Example Fire once when a specific market's YES outcome crosses up through 75%, then delete the subscription: ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "price_threshold", "filters": { "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_price": 0.75, "one_shot": true } } ``` ## Notes * The event is edge-triggered: it fires on the transition past the target, then re-arms once the price moves back to the other side (unless `one_shot` deleted the subscription). * The payload reports the crossing `direction` (`"up"` or `"down"`), the `threshold` that was crossed, and `previous_price` (the baseline the crossing was measured from). * `one_shot` requires `position_ids` or `condition_ids` so the subscription targets a bounded set of markets. # Pricing Source: https://docs.struct.to/webhooks/pricing How webhook usage is billed in credits. Webhooks are billed **per delivered event**. You're only charged when an event matching your subscription is successfully delivered to your endpoint. ## Per-Event Rates Each event type has its own rate per delivery: | Event | Rate per delivery | | --------------------------- | ----------------- | | `trader_first_trade` | 0.1 credits | | `trader_new_market` | 0.1 credits | | `trader_whale_trade` | 0.2 credits | | `trader_new_trade` | 0.2 credits | | `trader_trade_event` | 0.2 credits | | `trader_global_pnl` | 0.1 credits | | `trader_market_pnl` | 0.1 credits | | `trader_category_pnl` | 0.1 credits | | `trader_position_resolved` | 0.1 credits | | `trader_pnl_exits` | 0.025 credits | | `position_holder_metrics` | 0.025 credits | | `condition_holder_metrics` | 0.025 credits | | `event_holder_metrics` | 0.025 credits | | `condition_metrics` | 0.1 credits | | `event_metrics` | 0.1 credits | | `position_metrics` | 0.1 credits | | `tag_metrics` | 0.1 credits | | `market_volume_milestone` | 0.2 credits | | `event_volume_milestone` | 0.2 credits | | `position_volume_milestone` | 0.2 credits | | `price_spike` | 0.2 credits | | `market_volume_spike` | 0.2 credits | | `event_volume_spike` | 0.2 credits | | `position_volume_spike` | 0.2 credits | | `close_to_bond` | 0.2 credits | | `market_created` | 0.1 credits | | `oracle_events` | 0.1 credits | | `asset_price_tick` | 0.1 credits | | `asset_price_window_update` | 0.1 credits | Spike, milestone, and whale events cost 0.2 credits because they require heavier computation upstream. Exit markers and holder metrics cost 0.025 credits. All other events cost 0.1 credits. ## Integer Rounding Credits are an integer-based unit, so every charge is **rounded up** to the nearest whole credit. For example, if a billing period accumulates 4.3 credits of webhook usage, you'll be charged **5 credits**. This means very small amounts of usage still cost at least 1 credit. For accurate cost modelling, aggregate deliveries over longer windows rather than single events. ## Subscription Caps Each plan has a cap on how many webhook subscriptions you can create across your organisation: | Plan | Max webhooks | | ---------- | ------------ | | Free | 10 | | Hobby | 5,000 | | Startup | 25,000 | | Scale | 100,000 | | Enterprise | Unlimited | If you need higher limits, reach out to us at [support@struct.to](mailto:support@struct.to). ## Example Calculations A webhook subscribed to `trader_whale_trade` filtered to 50 wallets, receiving an average of 200 deliveries/day: ``` 200 × 0.2 = 40 credits/day ``` A webhook subscribed to `price_spike` filtered to 10 condition IDs, averaging 80 deliveries/day: ``` 80 × 0.2 = 16 credits/day ``` A webhook subscribed to `market_created` with no filters, averaging 15 new markets/day: ``` 15 × 0.1 = 1.5 → rounded up = 2 credits/day ``` ## Reducing Costs * **Use filters.** Every event type supports filters that narrow which deliveries reach your endpoint. A `trader_whale_trade` webhook filtered to specific wallets is far cheaper than one with no filters. * **Pick cheaper event types where possible.** If you only need to know that a trader entered a market, `trader_new_market` at 0.1 credits is half the cost of `trader_whale_trade` at 0.2. * **Scope each subscription tightly.** Each webhook targets a single event with its own filters. Point each subscription at the narrowest event and filter set that meets your need rather than a broad event you then discard deliveries from. * **Pause inactive webhooks.** If you temporarily don't need a subscription, pause it via the API rather than letting unused deliveries accumulate. # Response Format Source: https://docs.struct.to/webhooks/response-format Understand how webhook payloads are structured. Every webhook delivery sends a JSON payload to your configured endpoint. This structure is consistent across all event types. ## HTTP Headers Each delivery is an HTTP `POST` carrying these headers: | Header | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `X-Webhook-ID` | UUID of the webhook subscription that produced the delivery. | | `X-Delivery-ID` | UUID of this specific delivery. Matches the `id` field in the body — use it to deduplicate. | | `X-Event-Type` | The event name, e.g. `trader_whale_trade`. | | `X-Attempt` | Delivery attempt number, starting at `1`. | | `X-Webhook-Signature` | HMAC-SHA256 signature of the body, present only when the webhook has a `secret`. See [Signature Verification](/webhooks/signature-verification). | ## Payload Structure ```json theme={null} { "id": "4aa4e020-818d-4a76-af9d-b023dd9674d2", "event": "trader_whale_trade", "data": { ... }, "timestamp": 1773025181866, "webhook_id": "26cc4fb7-c62e-450c-a532-07196afd7b67", "attempt": 1 } ``` ## Fields ### `id` A unique identifier (UUID) for this specific webhook delivery. Use this to deduplicate events on your end. ### `event` The event type that triggered this webhook. Matches one of the event types you subscribed to during setup. ### `data` The event payload. Its shape depends on the `event` type. See the individual event documentation for field details. ### `timestamp` Unix timestamp in milliseconds indicating when the event was emitted. ### `webhook_id` The UUID of the webhook subscription that produced this delivery. Useful when you have multiple webhooks configured and need to identify the source. ### `attempt` The delivery attempt number, starting at `1`. If your endpoint fails to respond with a `2xx` status code, Struct retries the delivery and increments this value. ## Retries & failures A delivery succeeds only on a `2xx` response within the timeout. Otherwise it is retried: | Property | Value | | ----------------------- | ----------------------------------------- | | Success criteria | HTTP status `200`–`299` | | Request timeout | 30 seconds per attempt | | Max attempts | 10 | | Backoff | 1 second between attempts | | After all attempts fail | The webhook is automatically **disabled** | If 10 consecutive attempts fail, the subscription is set to `Disabled` and stops receiving events until you re-enable it with a `PUT /v1/webhooks/{id}`. Return `2xx` quickly — acknowledge first, then process the event out of band — so slow downstream work never trips the 30-second timeout. Because a delivery can be retried, the same event may arrive more than once. Deduplicate using the `id` field (or the `X-Delivery-ID` header). Fields within `data` may be `null` depending on the event type and the availability of enriched metadata at the time of delivery. # Signature Verification Source: https://docs.struct.to/webhooks/signature-verification Verify that a webhook delivery genuinely came from Struct using its HMAC signature. When you set a `secret` on a webhook subscription, Struct signs every delivery with an HMAC so you can confirm the request is genuine and the body was not tampered with in transit. Always verify signatures in production. Your endpoint is a public URL — without verification, anyone who discovers it can POST forged events to it. ## The signature header Each delivery includes: ``` X-Webhook-Signature: sha256= ``` * **Algorithm:** HMAC-SHA256. * **Key:** the `secret` you set when creating (or rotating) the webhook. * **Message:** the **raw request body bytes**, exactly as received — do not re-serialize the JSON before computing the HMAC. * **Encoding:** lowercase hex, prefixed with `sha256=`. There is no timestamp component, so you verify the body alone. ## How to verify Compute the HMAC-SHA256 of the raw body with your secret, prefix it with `sha256=`, and compare it to the header using a **constant-time** comparison. ```javascript Node.js (Express) theme={null} import crypto from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.WEBHOOK_SECRET; app.post( "/webhooks", express.raw({ type: "application/json" }), // keep the raw body (req, res) => { const received = req.get("X-Webhook-Signature") || ""; const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const a = Buffer.from(received); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(200); // ack fast, then process handleEvent(event); }, ); ``` ```python Python (FastAPI) theme={null} import hmac import hashlib import os from fastapi import FastAPI, Request, Response app = FastAPI() SECRET = os.environ["WEBHOOK_SECRET"].encode() @app.post("/webhooks") async def webhooks(request: Request): body = await request.body() # raw bytes received = request.headers.get("X-Webhook-Signature", "") expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(received, expected): return Response(status_code=401) event = await request.json() # ack fast, then process out of band return Response(status_code=200) ``` Frameworks that auto-parse JSON often discard the raw body. Make sure you hash the **bytes as received** — re-serializing the parsed object can reorder keys or change whitespace and break the signature. ## Other delivery headers Every delivery also carries these headers (handy for logging and routing): | Header | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `X-Webhook-ID` | UUID of the webhook subscription that produced the delivery. | | `X-Delivery-ID` | UUID of this specific delivery. Matches the `id` field in the body — use it to deduplicate. | | `X-Event-Type` | The event name, e.g. `trader_whale_trade`. | | `X-Attempt` | Delivery attempt number, starting at `1`. Struct retries failed deliveries — see the [`attempt` field](/webhooks/response-format#attempt). | | `X-Webhook-Signature` | The HMAC signature described above (only present when a `secret` is set). | ## Rotating your secret If a secret may have leaked, rotate it. New deliveries are signed with the new secret immediately: ```bash theme={null} curl -X POST https://api.struct.to/v1/webhooks/{webhook_id}/rotate-secret \ -H "X-API-Key: YOUR_API_KEY" ``` # Tag Metrics Source: https://docs.struct.to/webhooks/tag-metrics Fire a webhook when a tag's aggregated metrics cross your thresholds within a timeframe. **Event:** `tag_metrics` \ **Cost:** 0.1 credits per delivery The `tag_metrics` callback fires when a tag's aggregated metrics across all tagged markets cross your thresholds within a timeframe. The full payload schema is in the auto-generated [Tag Metrics callback](/api-reference/webhook-callbacks/tag-metrics-callback) reference; this page documents the filters and matching behavior. ## When to use this * Detect when total volume across every market under a tag crosses a threshold inside a rolling window. * Track category-level fee accrual or transaction count for tags like Politics or Sports. * Power "trending category" alerts driven by aggregated activity across tagged markets. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | -------------------- | --------- | ----------------------------------------------------------------------------------- | | `tags` | string\[] | Restrict to specific market tags (max 500). | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_fees` | number | Minimum fees accrued in USD. | | `min_txns` | integer | Minimum transaction count. | | `min_unique_traders` | integer | Minimum number of unique traders. | | `timeframes` | string\[] | One or more windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "tag_metrics", "filters": { "tags": ["politics", "elections"], "min_volume_usd": 100000, "timeframes": ["24h"] } } ``` ## Notes * `timeframes` selects which rolling windows are evaluated; the event fires when the thresholds are met in any listed window. * Metrics are aggregated across all markets carrying the listed tags. # Trader Category PnL Source: https://docs.struct.to/webhooks/trader-category-pnl Fire a webhook when a trader's aggregated realized PnL for a market category crosses your configured bounds. **Event:** `trader_category_pnl` \ **Cost:** 0.1 credits per delivery Fires when a trader's aggregated realized PnL across all markets in a category crosses your configured bounds. The full payload schema is in the auto-generated [Trader Category PnL callback](/api-reference/webhook-callbacks/category-pnl-callback) reference; this page documents the filters and matching behavior. ## When to use this * Alert when a trader's combined PnL across a market category (such as Politics or Sports) clears a profit or loss threshold. * Track category-level specialization by scoping to the categories a trader is active in rather than individual markets. * Qualify category conviction by pairing PnL bounds with volume, win rate, and the number of distinct markets traded. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | ---------------------- | --------- | ------------------------------------------------------- | | `traders` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `categories` | string\[] | Restrict to specific market categories (max 500). | | `min_realized_pnl_usd` | number | Minimum realized PnL in USD. | | `max_realized_pnl_usd` | number | Maximum realized PnL in USD. | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_buy_usd` | number | Minimum buy-side volume in USD. | | `min_sell_volume_usd` | number | Minimum sell-side volume in USD. | | `min_win_rate` | number | Minimum win rate, `0.0`–`100.0`. | | `min_markets_traded` | integer | Minimum number of distinct markets traded. | | `timeframes` | string\[] | One or more PnL windows: `1d`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_category_pnl", "filters": { "categories": ["Politics"], "min_realized_pnl_usd": 20000, "min_win_rate": 55.0, "min_markets_traded": 3, "timeframes": ["30d"] } } ``` ## Notes * Combine `min_realized_pnl_usd` and `max_realized_pnl_usd` to match a PnL band rather than a single bound. * `timeframes` scopes the window over which aggregated category PnL is measured; `lifetime` uses the trader's full history in that category. # Trader First Trade Source: https://docs.struct.to/webhooks/trader-first-trade Fire a webhook the very first time a tracked trader ever trades on Polymarket. **Event:** `trader_first_trade` \ **Cost:** 0.1 credits per delivery The `trader_first_trade` event fires when a tracked trader executes their first-ever trade on Polymarket, delivering exactly once per trader for the lifetime of their account. The full payload schema is in the auto-generated [Trader First Trade callback](/api-reference/webhook-callbacks/first-trade-callback) reference; this page documents the filters and matching behavior. ## When to use this * Onboard newly active wallets into a watchlist the moment they place their debut trade. * Detect fresh capital entering Polymarket before a trader builds any history. * Trigger welcome or attribution flows that should run once per trader, never on repeat trades. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ------------------------------------------------------- | | `wallet_addresses` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `min_usd_value` | number | Minimum USD size of the trade. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_first_trade", "filters": { "min_usd_value": 100, "exclude_shortterm_markets": true } } ``` # Trader Global PnL Source: https://docs.struct.to/webhooks/trader-global-pnl Fire a webhook when a trader's global realized PnL crosses your configured bounds. **Event:** `trader_global_pnl` \ **Cost:** 0.1 credits per delivery Fires when a trader's global realized PnL across all markets crosses your configured bounds. The full payload schema is in the auto-generated [Trader Global PnL callback](/api-reference/webhook-callbacks/global-pnl-callback) reference; this page documents the filters and matching behavior. ## When to use this * Surface traders whose all-market realized PnL clears a profit or loss threshold. * Track aggregate performance of a watchlist of wallets across every market they touch. * Qualify high-conviction traders by combining global PnL with volume, win rate, and breadth of markets traded. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | ---------------------- | --------- | ------------------------------------------------------- | | `traders` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `min_realized_pnl_usd` | number | Minimum realized PnL in USD. | | `max_realized_pnl_usd` | number | Maximum realized PnL in USD. | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_buy_usd` | number | Minimum buy-side volume in USD. | | `min_sell_volume_usd` | number | Minimum sell-side volume in USD. | | `min_win_rate` | number | Minimum win rate, `0.0`–`100.0`. | | `min_markets_traded` | integer | Minimum number of distinct markets traded. | | `timeframes` | string\[] | One or more PnL windows: `1d`, `7d`, `30d`, `lifetime`. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_global_pnl", "filters": { "min_realized_pnl_usd": 10000, "min_win_rate": 60.0, "min_markets_traded": 5, "timeframes": ["7d"] } } ``` ## Notes * Combine `min_realized_pnl_usd` and `max_realized_pnl_usd` to match a PnL band rather than a single bound. * `timeframes` scopes the window over which realized PnL is measured; `lifetime` uses the trader's full history. # Trader Market PnL Source: https://docs.struct.to/webhooks/trader-market-pnl Fire a webhook when a trader's realized PnL in a specific market crosses your configured bounds. **Event:** `trader_market_pnl` \ **Cost:** 0.1 credits per delivery Fires when a trader's realized PnL in a specific market (condition) crosses your configured bounds. The full payload schema is in the auto-generated [Trader Market PnL callback](/api-reference/webhook-callbacks/market-pnl-callback) reference; this page documents the filters and matching behavior. ## When to use this * Alert when a trader books a target profit or loss inside a single market you care about. * Scope PnL tracking to specific condition IDs or event slugs instead of a trader's whole book. * Spot outsized per-market wins or drawdowns by pairing PnL bounds with volume filters. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ------------------------------------------------------- | | `traders` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `min_realized_pnl_usd` | number | Minimum realized PnL in USD. | | `max_realized_pnl_usd` | number | Maximum realized PnL in USD. | | `min_volume_usd` | number | Minimum traded volume in USD. | | `max_volume_usd` | number | Maximum traded volume in USD. | | `min_buy_usd` | number | Minimum buy-side volume in USD. | | `min_sell_volume_usd` | number | Minimum sell-side volume in USD. | | `timeframes` | string\[] | One or more PnL windows: `1d`, `7d`, `30d`, `lifetime`. | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_market_pnl", "filters": { "condition_ids": ["0x1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef90"], "min_realized_pnl_usd": 5000, "min_volume_usd": 25000, "timeframes": ["7d"] } } ``` ## Notes * Combine `min_realized_pnl_usd` and `max_realized_pnl_usd` to match a PnL band rather than a single bound. * `timeframes` scopes the window over which per-market realized PnL is measured; `lifetime` uses the trader's full history in that market. # Trader New Market Source: https://docs.struct.to/webhooks/trader-new-market Fire a webhook the first time a trader trades in a specific market. **Event:** `trader_new_market` \ **Cost:** 0.1 credits per delivery The `trader_new_market` event fires the first time a trader trades in a specific market, delivering once per trader-and-market pair. The full payload schema is in the auto-generated [Trader New Market callback](/api-reference/webhook-callbacks/new-market-entry-callback) reference; this page documents the filters and matching behavior. **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. ## When to use this * Track when a trader expands into a market they have never touched before. * Spot smart money rotating into a fresh event or condition for the first time. * Build per-market entry alerts without re-firing on subsequent trades in the same market. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ------------------------------------------------------- | | `wallet_addresses` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `min_usd_value` | number | Minimum USD size of the trade. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_new_market", "filters": { "wallet_addresses": ["0x1f98431c8ad98523631ae4a59f267346ea31f984"], "min_usd_value": 50 } } ``` # Trader New Trade Source: https://docs.struct.to/webhooks/trader-new-trade Fire a webhook on every order-filled trade by a tracked trader. **Event:** `trader_new_trade` \ **Cost:** 0.2 credits per delivery The `trader_new_trade` event fires on every order-filled trade by a tracked trader, delivering once per fill. The full payload schema is in the auto-generated [Trader New Trade callback](/api-reference/webhook-callbacks/new-trade-callback) reference; this page documents the filters and matching behavior. **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. ## When to use this * Mirror a trader's full activity into your own ledger or analytics pipeline. * Drive copy-trading logic that reacts to each individual fill. * Stream a real-time feed of a watched wallet's buys and sells, optionally narrowed by trade type. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | -------------------------------------------------------------------------------- | | `wallet_addresses` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `min_usd_value` | number | Minimum USD size of the trade. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `trade_types` | string\[] | Restrict to specific fill trade types: `OrderFilled`, `OrdersMatched` (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_new_trade", "filters": { "wallet_addresses": ["0x1f98431c8ad98523631ae4a59f267346ea31f984"], "trade_types": ["OrderFilled"], "min_usd_value": 25 } } ``` # Trader PnL Exits Source: https://docs.struct.to/webhooks/trader-pnl-exits Fire a webhook each time a trader closes a position, with the realized PnL and exit reason. **Event:** `trader_pnl_exits` \ **Cost:** 0.025 credits per delivery Fires once each time a trader closes a position, carrying the realized PnL at exit and a reason describing how the position was closed. The full payload schema is in the auto-generated [Trader PnL Exits callback](/api-reference/webhook-callbacks/exit-markers-callback) reference; this page documents the filters and matching behavior. ## When to use this * Overlay exit markers on a trader's PnL chart as positions are closed. * Distinguish positions held to resolution from those sold early using the exit reason. * Stream realized outcomes for a watchlist of traders, markets, or events as they happen. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------- | --------- | ------------------------------------------------------- | | `traders` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | ## Exit reasons Each delivery carries a `reason` describing how the position was closed: | Reason | Meaning | | --------------- | --------------------------------------------------------- | | `resolved_win` | Held to market resolution and won by the market verdict. | | `resolved_loss` | Held to market resolution and lost by the market verdict. | | `sold_win` | Closed before resolution with positive realized PnL. | | `sold_loss` | Closed before resolution with negative realized PnL. | The `resolved_*` reasons reflect the settled market outcome, while the `sold_*` reasons reflect the sign of realized PnL on an early exit. ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_pnl_exits", "filters": { "traders": ["0xabc1230000000000000000000000000000000000"], "event_slugs": ["us-presidential-election-2028"] } } ``` ## Notes * One delivery is sent per position close, so a trader who exits several positions produces several events. * The payload includes realized PnL in USD and as a percentage, the cost basis, and the exit reason. # Trader Position Resolved Source: https://docs.struct.to/webhooks/trader-position-resolved Fire a webhook when a trader's position resolves as its market settles. **Event:** `trader_position_resolved` \ **Cost:** 0.1 credits per delivery Fires when a trader's position resolves, that is, when the market backing the position settles and the win or loss is final. The full payload schema is in the auto-generated [Trader Position Resolved callback](/api-reference/webhook-callbacks/position-resolved-callback) reference; this page documents the filters and matching behavior. ## When to use this * Book final outcomes for a watchlist of traders the moment their positions settle. * Reconcile open positions to closed at resolution time without polling. * Track only winning resolutions (or only losing ones) by combining the resolution event with the `won_only` filter. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | ----------------- | ---------- | ---------------------------------------------------------------------------------------------------- | | `traders` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `outcome_indexes` | integer\[] | Restrict to specific outcome indexes within a market. | | `won_only` | boolean | When `true`, deliver only winning resolutions; when `false`, only losing ones. Omit to receive both. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_position_resolved", "filters": { "traders": ["0xabc1230000000000000000000000000000000000"], "won_only": true } } ``` ## Notes * The payload is the full resolved position, including the resolved outcome, realized PnL, and cost basis. * The event fires once per position when its market resolves; it does not fire on intermediate trades or price moves. * Omit `won_only` to receive every resolution regardless of whether the position won or lost. # Trader Trade Event Source: https://docs.struct.to/webhooks/trader-trade-event Receive a typed prediction-trade stream of a tracked trader's trade events. **Event:** `trader_trade_event` \ **Cost:** 0.2 credits per delivery The `trader_trade_event` event delivers a typed prediction-trade stream of a tracked trader's trade events, with each delivery carrying the structured event type. The full payload schema is in the auto-generated [Trader Trade Event callback](/api-reference/webhook-callbacks/typed-trade-event-callback) reference; this page documents the filters and matching behavior. ## When to use this * Consume a structured, typed event feed rather than raw fills for downstream classification. * Branch your handling logic on trade type while keeping a single subscription. * Feed a normalized prediction-trade timeline into dashboards or alerting systems. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ----------------------------------------------------------------------------------------------- | | `wallet_addresses` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `min_usd_value` | number | Minimum USD size of the trade. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `trade_types` | string\[] | Restrict to specific trade types, e.g. `OrderFilled`, `Redemption`, `Merge`, `Split` (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_trade_event", "filters": { "wallet_addresses": ["0x1f98431c8ad98523631ae4a59f267346ea31f984"], "trade_types": ["OrderFilled", "Redemption"], "min_usd_value": 100 } } ``` # Trader Whale Trade Source: https://docs.struct.to/webhooks/trader-whale-trade Fire a webhook when a tracked trader's trade clears your size and probability thresholds. **Event:** `trader_whale_trade` \ **Cost:** 0.2 credits per delivery The `trader_whale_trade` event fires when a trade exceeds the configured size and optional probability thresholds. The full payload schema is in the auto-generated [Trader Whale Trade callback](/api-reference/webhook-callbacks/whale-trade-callback) reference; this page documents the filters and matching behavior. **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. ## When to use this * Surface only the large, market-moving trades from a watched set of wallets. * Gate alerts to high-conviction positions by pairing size with a probability band. * Cut noise from small fills so your feed carries just the whale-sized activity. ## Subscription filters Add these to the `filters` object when you create the subscription. | Filter | Type | Description | | --------------------------- | --------- | ------------------------------------------------------- | | `wallet_addresses` | string\[] | Restrict to specific trader wallet addresses (max 500). | | `min_usd_value` | number | Minimum USD size of the trade. | | `min_price` | number | Minimum outcome probability, `0.0`–`1.0`. | | `max_price` | number | Maximum outcome probability, `0.0`–`1.0`. | | `condition_ids` | string\[] | Restrict to specific markets by condition ID (max 500). | | `event_slugs` | string\[] | Restrict to specific events by slug (max 500). | | `exclude_shortterm_markets` | boolean | Exclude short-term Up/Down markets. | ## Example ```json theme={null} { "url": "https://your-server.com/webhooks", "event": "trader_whale_trade", "filters": { "min_usd_value": 10000, "min_price": 0.1, "max_price": 0.9 } } ``` ## Notes * Set `min_usd_value` to define what counts as a whale-sized trade; without it there is no size floor and every trade matches. * The probability filters are optional and narrow matches to a specific confidence band. # Use Cases Source: https://docs.struct.to/webhooks/use-cases Why use webhooks and what you can do with them. ## Why Webhooks? Stop wasting resources polling the API on an interval. Webhooks push data to your server the moment an event occurs. No cron jobs, no wasted requests, no delays. Receive events as they happen, not minutes later. React to trades, probability shifts, and volume spikes with sub-second delivery. No need to run your own infrastructure for cron jobs, polling workers, or background queues. Webhooks handle the delivery, so you just process the event. Any server that can accept an HTTP POST request can receive webhooks. No SDKs, no websocket connections to maintain, no special infrastructure. ## What You Can Build Forward webhook payloads to a Telegram bot or Discord channel. Get instant notifications on your phone when a whale trades, a market spikes, or your PnL crosses a threshold. Trigger buy/sell logic directly from webhook events. Copy-trade whale wallets, hedge on probability shifts, or auto-enter newly created markets. Pipe events into your database or streaming pipeline to power real-time analytics dashboards without polling for updates. Set up PnL alerts, volume anomaly detection, or market resolution monitors that notify your team through Slack, email, or PagerDuty. Stream every trade, metric update, and price tick directly into your data warehouse for backtesting, research, or ML model training. Combine prediction market events with on-chain asset price feeds to build cross-market trading signals and arbitrage strategies. # Asset Price Tick Source: https://docs.struct.to/websockets/alerts/asset-price-tick Get notified on every raw Chainlink price tick for tracked crypto assets. **Event:** `asset_price_tick` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified on every raw Chainlink price tick for tracked crypto assets. Optionally filter by asset symbol to receive only the ticks you need. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) builds on these asset price ticks to chart the spot price behind Up/Down markets. ## Filters | Filter | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `asset_symbols` | string\[] | No | Supported symbols: `BTC`, `ETH`, `SOL`, `XRP`, `DOGE`, `BNB`, `HYPE` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "asset_price_tick", "asset_symbols": ["BTC", "ETH"] } ``` ## Response ```json theme={null} { "event": "asset_price_tick", "timestamp": 1775913505260, "data": { "symbol": "BTC", "price": 84532.17, "timestamp_ms": 1775913505100 } } ``` # Asset Price Window Update Source: https://docs.struct.to/websockets/alerts/asset-price-window-update Get notified when a crypto asset candle opens or closes. **Event:** `asset_price_window_update` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a crypto asset candle opens or closes. Use filters to narrow alerts to specific symbols or timeframes. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) builds on these window open and close events to resolve Up/Down outcomes. ## Filters | Filter | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `asset_symbols` | string\[] | No | Supported symbols: `BTC`, `ETH`, `SOL`, `XRP`, `DOGE`, `BNB`, `HYPE` (max 500) | | `timeframes` | string\[] | No | Valid timeframes: `5m`, `15m`, `1h`, `4h`, `1d`, `24h` (max 500) | The backend normalizes `1d` to `24h` in the response `variant` field. ## Subscribe ```json theme={null} { "op": "subscribe", "event": "asset_price_window_update", "asset_symbols": ["BTC", "ETH"], "timeframes": ["1h", "4h"] } ``` ## Response ```json theme={null} { "event": "asset_price_window_update", "timestamp": 1775913505260, "data": { "symbol": "BTC", "variant": "1h", "start_time": 1775910000000, "end_time": 1775913600000, "open_price": 84120.50, "close_price": 84532.17, "update_type": "close" } } ``` # Close to Bond Source: https://docs.struct.to/websockets/alerts/close-to-bond Get notified when a trade occurs at a near-certain-outcome price. **Event:** `close_to_bond` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a trade occurs at a near-certain-outcome price. At least one of `min_price` or `max_price` is required to define the bond zone. **Related guide:** [Bond-zone alerts](/guides/bond-zone-alerts) is a worked walkthrough of this event across both websockets and webhooks. ## Defining the bond zone The bond zone is defined entirely by how you combine `min_price` and `max_price`. The **relationship between the two values** selects one of four modes: | You set | Mode | Fires when | `bond_side` | | -------------------------- | ---------------- | ---------------------------------------------- | ------------------- | | `min_price` only | Single high edge | `price ≥ min_price` | `"high"` | | `max_price` only | Single low edge | `price ≤ max_price` | `"low"` | | Both, with **`min < max`** | Bounded range | `min_price ≤ price ≤ max_price` | `"high"` | | Both, with **`min > max`** | Two edges | `price ≥ min_price` **or** `price ≤ max_price` | `"high"` or `"low"` | **`min < max` is a band, not a bond.** If you set `min_price: 0.75` and `max_price: 0.90`, you do **not** get "fires above 90% or below 75%". Because `min < max`, it switches to bounded-range mode and fires only when the price lands **inside** the 75–90% band. To alert on the two near-certain extremes, you must set `min > max` (see below). ## Alerting on near-certain outcomes To get notified when an outcome becomes near-certain in **either** direction (at or above 90% **or** at or below 10%), set `min_price` **higher** than `max_price`: ```json theme={null} { "min_price": 0.90, "max_price": 0.10 } ``` Because `0.90 > 0.10`, this is read as two separate edges. A trade at 96¢ fires with `bond_side: "high"`; a trade at 4¢ fires with `bond_side: "low"`. For a single edge, set just one of the two. ## How price is read * The traded position's own `price` is used: the price of the exact outcome token (`position_id`) that printed. * Trades at a price of exactly `0` or `1` are skipped, since there is no remaining risk to alert on. * `price` is on a `0.0`–`1.0` scale (so 95¢ = `0.95`). ## Picking the right side On a binary market, "YES at ≤10%" and "NO at ≥90%" are the **same** event priced from opposite tokens. If you add `position_outcome_indices: [0]` you will only ever see trades that print on the **Yes/Up** token (index `0`); trades on the No token (index `1`) won't fire even when they hit the same bond zone. Omit `position_outcome_indices` to catch the zone regardless of which side the trade prints on. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `min_price` | number | Yes\* | High zone threshold, 0.0-1.0 (e.g. 0.95). \*At least one of `min_price` or `max_price` is required. | | `max_price` | number | Yes\* | Low zone threshold, 0.0-1.0 (e.g. 0.05). \*At least one of `min_price` or `max_price` is required. | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `position_outcome_indices` | number\[] | No | Filter by outcome index: 0 or 1 (max 500) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `tags` | string\[] | No | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500) | | `series_slugs` | string\[] | No | Restrict to markets in any of these series by slug, case-insensitive (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term markets from results | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. ## Subscribe Single high edge: fire when an outcome is near-certain (≥ 95%): ```json theme={null} { "op": "subscribe", "event": "close_to_bond", "min_price": 0.95 } ``` Both edges: fire when an outcome is near-certain in either direction (≥ 90% or ≤ 10%). Note `min_price` is set **higher** than `max_price`: ```json theme={null} { "op": "subscribe", "event": "close_to_bond", "min_price": 0.90, "max_price": 0.10 } ``` ## Response ```json theme={null} { "event": "close_to_bond", "timestamp": 1775913505260, "data": { "trader": "0x9d84cef98b41a5b88dafb55c05e9a7e0f1a4f4d6", "taker": "0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e", "position_id": "452312848583266388373324160190187140051835877600158453279131187530910662656", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "Yes", "outcome_index": 0, "question": "Will candidate X win the 2028 election?", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "trade_id": "0x9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b", "hash": "0x1f2e3d4c5b6a7988deadbeefcafebabe0011223344556677889900aabbccddee", "block": 19456789, "confirmed_at": 1713012000, "amount_usd": 5000.00, "shares_amount": 5263.16, "fee": 50.00, "side": "Buy", "price": 0.96, "bond_side": "high", "threshold": 0.95 } } ``` # Condition Holder Metrics Source: https://docs.struct.to/websockets/alerts/condition-holder-metrics Get notified when holder metrics update for a market. **Event:** `condition_holder_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.025 credits per event Fires when the holder count updates for one of the markets (conditions) you subscribe to. The `condition_ids` filter is required: you only receive metrics for the condition IDs you name. ## Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | -------------------------------- | | `condition_ids` | `string[]` | Yes | Condition IDs to track (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "condition_holder_metrics", "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"] } ``` ## Response ```json theme={null} { "event": "condition_holder_metrics", "timestamp": 1775913505260, "data": { "ts": 1713052800, "block": 68554321, "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "holder_count": 3050 } } ``` # Condition Metrics Source: https://docs.struct.to/websockets/alerts/condition-metrics Get notified when a market's volume or transaction metrics cross a threshold. **Event:** `condition_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a market's volume or transaction metrics cross a threshold. Use filters to narrow alerts to specific conditions, volume ranges, or timeframes. ## Filters | Filter | Type | Required | Description | | -------------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `min_volume_usd` | number | No | Minimum volume in USD | | `max_volume_usd` | number | No | Maximum volume in USD | | `min_fees` | number | No | Minimum fees in USD | | `min_txns` | number | No | Minimum transaction count | | `min_unique_traders` | number | No | Minimum unique trader count | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "condition_metrics", "min_volume_usd": 1000, "timeframes": ["5m", "1h"] } ``` ## Response **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. ```json theme={null} { "event": "condition_metrics", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "timeframe": "5m", "volume_usd": 15230.50, "shares_volume": 23430.0, "builder_usd_volume": 3850.0, "builder_shares_volume": 5920.0, "fees": 152.30, "builder_fees": 38.5, "txns": 47, "builder_txns": 12, "unique_traders": 23, "unique_builder_traders": 6 } } ``` Each payload includes builder-attributed counterparts for volume, fees, transactions, and unique traders so you can monitor builder-routed activity separately. # Event Holder Metrics Source: https://docs.struct.to/websockets/alerts/event-holder-metrics Get notified when holder metrics update for an event. **Event:** `event_holder_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.025 credits per event Fires when the holder count updates for one of the events you subscribe to, aggregated across every market in that event. The `event_slugs` filter is required: you only receive metrics for the event slugs you name. ## Filters | Filter | Type | Required | Description | | ------------- | ---------- | -------- | ------------------------------ | | `event_slugs` | `string[]` | Yes | Event slugs to track (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "event_holder_metrics", "event_slugs": ["2026-us-midterm-elections"] } ``` ## Response ```json theme={null} { "event": "event_holder_metrics", "timestamp": 1775913505260, "data": { "ts": 1713052800, "block": 68554321, "event_slug": "2026-us-midterm-elections", "holder_count": 9120 } } ``` # Event Metrics Source: https://docs.struct.to/websockets/alerts/event-metrics Get notified when an event's aggregated metrics cross a threshold. **Event:** `event_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when an event's aggregated metrics cross a threshold. Filter by event slugs, volume ranges, or timeframes to receive only the alerts you need. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `min_volume_usd` | number | No | Minimum volume in USD | | `max_volume_usd` | number | No | Maximum volume in USD | | `min_fees` | number | No | Minimum fees in USD | | `min_txns` | number | No | Minimum transaction count | | `min_unique_traders` | number | No | Minimum unique trader count | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "event_metrics", "min_volume_usd": 5000, "timeframes": ["1h", "24h"] } ``` ## Response **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. ```json theme={null} { "event": "event_metrics", "timestamp": 1775913505260, "data": { "event_slug": "us-presidential-election-2028", "timeframe": "1h", "volume_usd": 82410.25, "shares_volume": 126800.0, "builder_usd_volume": 20800.0, "builder_shares_volume": 32000.0, "fees": 824.10, "builder_fees": 208.0, "txns": 215, "builder_txns": 54, "unique_traders": 98, "unique_builder_traders": 25 } } ``` Each payload includes builder-attributed counterparts for volume, fees, transactions, and unique traders so you can monitor builder-routed activity separately. # Event Volume Milestone Source: https://docs.struct.to/websockets/alerts/event-volume-milestone Get notified when an event's aggregated volume crosses a USD milestone. **Event:** `event_volume_milestone` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when an event's aggregated volume crosses a USD milestone. You must specify at least one timeframe to subscribe. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- | | `timeframes` | string\[] | Yes | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `milestone_amounts` | integer\[] | No | USD milestone amounts to watch, positive integers (max 500). If empty, no events fire | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "event_volume_milestone", "timeframes": ["1h", "24h"], "milestone_amounts": [50000, 100000] } ``` ## Response ```json theme={null} { "event": "event_volume_milestone", "timestamp": 1775913505260, "data": { "event_slug": "us-presidential-election-2028", "timeframe": "24h", "milestone_usd": 100000, "current_volume_usd": 103450.60, "fees": 1034.50, "txns": 287 } } ``` # Event Volume Spike Source: https://docs.struct.to/websockets/alerts/event-volume-spike Get notified when an event's aggregated volume exceeds its baseline by a configured ratio. **Event:** `event_volume_spike` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when an event's aggregated volume exceeds its baseline by a configured ratio. The `spike_ratio` filter is required and defines the volume multiplier threshold. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `spike_ratio` | number | Yes | Volume multiplier threshold, must be greater than 1.0 (e.g. 2.0 = 2x baseline) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `window_secs` | number | No | Window reset interval in seconds (1-600) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "event_volume_spike", "spike_ratio": 3.0, "timeframes": ["1h"] } ``` ## Response ```json theme={null} { "event": "event_volume_spike", "timestamp": 1775913505260, "data": { "event_slug": "us-presidential-election-2028", "event_title": "US Presidential Election 2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/us-presidential-election-2028.png", "timeframe": "1h", "current_volume_usd": 156000.00, "snapshot_volume_usd": 48000.00, "delta_volume_usd": 108000.00, "spike_pct": 225.00, "txns": 412, "fees": 1560.00 } } ``` # Getting Started Source: https://docs.struct.to/websockets/alerts/getting-started Receive webhook-style events over a websocket connection. ## Overview The Alerts endpoint lets you receive the same events as Struct's webhooks (trader whale trades, PnL crossings, price spikes, volume milestones, and more) pushed directly over a websocket connection instead of HTTP POSTs. This is useful when you want the low-latency push model of webhooks without having to host a public HTTP endpoint. Alerts are especially well-suited for building in-app notification systems on trading platforms, letting your users track specific wallets, listen for price spikes on markets they care about, get notified when volume milestones are hit, or follow whale activity in real time. Because subscriptions are scoped to a single connection, you can open a dedicated socket per logged-in user and dynamically subscribe and unsubscribe as they adjust their alert preferences, without touching any server-side webhook configuration. ## Endpoint ``` wss://api.struct.to/ws/alerts?api-key=YOUR_API_KEY ``` Alerts use the same API key authentication as the Rooms endpoint. Subscriptions are ephemeral: they live only for the duration of the connection and are dropped on disconnect. ## Subscribing Subscribe to an alert type by sending a JSON message. The `op` and `event` fields identify the subscription; all filter fields are flat on the top level of the message. ```json theme={null} { "op": "subscribe", "event": "trader_whale_trade", "wallet_addresses": ["0xabc..."], "min_usd_value": 10000 } ``` The server acknowledges with: ```json theme={null} { "op": "subscribed", "event": "trader_whale_trade", "subscription_id": "b9c0e4b0-1a2d-4e3f-9c0a-111122223333" } ``` ## Unsubscribing Send an `unsubscribe` message whose filter fields match the original subscription exactly: ```json theme={null} { "op": "unsubscribe", "event": "trader_whale_trade", "wallet_addresses": ["0xabc..."], "min_usd_value": 10000 } ``` Response: ```json theme={null} { "op": "unsubscribed", "event": "trader_whale_trade" } ``` ## Message Format Each alert is pushed as JSON with the event name, a millisecond Unix timestamp, and the payload: ```json theme={null} { "event": "trader_whale_trade", "timestamp": 1775913505260, "data": { ... } } ``` ## Available Events ### Trader Events | Event | Description | | ------------------------------------------------------------------------- | -------------------------------------------------------------- | | [`trader_first_trade`](/websockets/alerts/trader-first-trade) | First-ever trade by a trader on Polymarket | | [`trader_new_market`](/websockets/alerts/trader-new-market) | Trader's first trade in a specific market | | [`trader_new_trade`](/websockets/alerts/trader-new-trade) | Every order-filled trade, with optional filters | | [`trader_trade_event`](/websockets/alerts/trader-trade-event) | Typed trade-event union covering every confirmed trade variant | | [`trader_whale_trade`](/websockets/alerts/trader-whale-trade) | Trade exceeds configured size and probability thresholds | | [`trader_global_pnl`](/websockets/alerts/trader-global-pnl) | Trader's global PnL crosses a threshold | | [`trader_market_pnl`](/websockets/alerts/trader-market-pnl) | Trader's per-market PnL crosses a threshold | | [`trader_category_pnl`](/websockets/alerts/trader-category-pnl) | Trader's per-category PnL crosses a threshold | | [`trader_position_resolved`](/websockets/alerts/trader-position-resolved) | A trader's position resolves win or loss | | [`trader_pnl_exits`](/websockets/alerts/trader-pnl-exits) | A trader closes a position, with the realized result | ### Holders | Event | Description | | ------------------------------------------------------------------------- | ------------------------------------ | | [`position_holder_metrics`](/websockets/alerts/position-holder-metrics) | Holder metrics update for a position | | [`condition_holder_metrics`](/websockets/alerts/condition-holder-metrics) | Holder metrics update for a market | | [`event_holder_metrics`](/websockets/alerts/event-holder-metrics) | Holder metrics update for an event | ### Metrics | Event | Description | | ----------------------------------------------------------- | ------------------------------------------------------ | | [`condition_metrics`](/websockets/alerts/condition-metrics) | Market volume or transaction metrics cross a threshold | | [`event_metrics`](/websockets/alerts/event-metrics) | Event aggregated metrics cross a threshold | | [`position_metrics`](/websockets/alerts/position-metrics) | Position metrics cross a threshold | ### Volume Milestones | Event | Description | | --------------------------------------------------------------------------- | --------------------------------------- | | [`market_volume_milestone`](/websockets/alerts/market-volume-milestone) | Market volume crosses a USD milestone | | [`event_volume_milestone`](/websockets/alerts/event-volume-milestone) | Event volume crosses a USD milestone | | [`position_volume_milestone`](/websockets/alerts/position-volume-milestone) | Position volume crosses a USD milestone | ### Spikes | Event | Description | | ------------------------------------------------------------------- | ----------------------------------------------------- | | [`price_spike`](/websockets/alerts/price-spike) | Position price changes rapidly | | [`price_threshold`](/websockets/alerts/price-threshold) | Outcome price crosses a target level (edge-triggered) | | [`market_volume_spike`](/websockets/alerts/market-volume-spike) | Market volume exceeds baseline by spike ratio | | [`event_volume_spike`](/websockets/alerts/event-volume-spike) | Event volume exceeds baseline by spike ratio | | [`position_volume_spike`](/websockets/alerts/position-volume-spike) | Position volume exceeds baseline by spike ratio | ### Market Lifecycle | Event | Description | | ------------------------------------------------------- | --------------------------------------------------- | | [`close_to_bond`](/websockets/alerts/close-to-bond) | Trade at a near-certain-outcome price | | [`market_created`](/websockets/alerts/market-created) | New prediction market detected on-chain | | [`oracle_events`](/websockets/alerts/oracle-events) | Typed UMA oracle lifecycle event affecting a market | | [`market_resolved`](/websockets/alerts/market-resolved) | Market reaches a terminal resolution | | [`market_disputed`](/websockets/alerts/market-disputed) | Proposed resolution challenged on-chain | ### Asset Prices | Event | Description | | --------------------------------------------------------------------------- | ------------------------------------------- | | [`asset_price_tick`](/websockets/alerts/asset-price-tick) | Raw Chainlink price tick for a crypto asset | | [`asset_price_window_update`](/websockets/alerts/asset-price-window-update) | Crypto asset candle open or close | ## Errors Invalid messages return a JSON error: ```json theme={null} { "error": "unknown event type" } ``` ## Keepalive Send a periodic ping to keep the connection alive: ```json theme={null} { "type": "ping" } ``` The server responds with `{ "type": "pong" }`. ## Pricing Alerts are billed **per delivered event** depending on the event type (0.025, 0.1, or 0.2 credits), with a standard 1 credit connection hold on connect. See each alert's page for its specific cost, or visit [Pricing](/websockets/pricing) for details. ## Rooms vs. Alerts | | Rooms (`/ws`) | Alerts (`/ws/alerts`) | | ------------- | ------------------------------------------ | ---------------------------------- | | **Shape** | Raw data streams (trades, prices, metrics) | Event-triggered notifications | | **Filtering** | Room-specific filter fields | Same filters as webhooks | | **Billing** | Per-message rate by room | Per-event rate by event type | | **Use case** | Dashboards, trading UIs, analytics | Notifications, agents, automations | # Market Created Source: https://docs.struct.to/websockets/alerts/market-created Get notified when a new prediction market is detected on-chain. **Event:** `market_created` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a new prediction market is detected on-chain. Optionally filter by event slugs, tags, or exclude short-term markets. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `tags` | string\[] | No | Restrict by tag or category, given as the display label shown on Polymarket (e.g. `"Sports"`, `"FIFA World Cup"`), not a slug. Case-insensitive (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "market_created", "tags": ["Sports", "FIFA World Cup"] } ``` ## Response ```json theme={null} { "event": "market_created", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "event_id": "12345", "event_title": "US Presidential Election 2028", "series_slug": "us-elections", "outcomes": [ { "index": 0, "name": "Yes", "position_id": "452312848583266388373324160190187140051835877600158453279131187530910662656" }, { "index": 1, "name": "No", "position_id": "21742633143463906290569050155826241533067272736897614950488156847949938836876" } ], "question": "Will candidate X win the 2028 election?", "title": "Candidate X Wins 2028 Election", "description": "Resolves Yes if candidate X wins the 2028 US presidential election.", "category": "Politics", "tags": ["politics", "elections"], "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-candidate-x-win.png", "neg_risk": false } } ``` # Market Disputed Source: https://docs.struct.to/websockets/alerts/market-disputed Get notified when a proposed market resolution is challenged on-chain. **Event:** `market_disputed` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a proposed market resolution is challenged on-chain (`DisputePrice` or `AssertionDisputed`). The resolution is now contested and delayed. The payload carries the disputer, the proposer, and the disputed or proposed outcome when available. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | ------------------------------------------------ | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term `updown` markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "market_disputed", "exclude_shortterm_markets": true } ``` ## Response ```json theme={null} { "event": "market_disputed", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "dispute_kind": "dispute_price", "disputer": "0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e", "proposer": "0x9d84cef98b41a5b88dafb55c05e9a7e0f1a4f4d6", "proposed_outcome": "Yes", "proposed_price": 1.0, "question": "Will candidate X win the 2028 election?", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-candidate-x-win.png", "hash": "0x1f2e3d4c5b6a7988deadbeefcafebabe0011223344556677889900aabbccddee", "block": 19456789, "confirmed_at": 1713012000 } } ``` `dispute_kind` is either `dispute_price` or `assertion_disputed`. `proposed_price` is present only for `DisputePrice` disputes. Pair this with [`market_resolved`](/websockets/alerts/market-resolved) to follow a market from contested back to final. # Market Resolved Source: https://docs.struct.to/websockets/alerts/market-resolved Get notified once when a market reaches a terminal resolution. **Event:** `market_resolved` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified once when a market reaches a terminal resolution. The alert collapses the underlying oracle events (`QuestionResolved`, `QuestionEmergencyResolved`, `ConditionResolution`, `NegRiskOutcomeReported`) into a single notification carrying the winning outcome and, when available, the settled price. Prefer this over [`oracle_events`](/websockets/alerts/oracle-events) when you only care about the final result. A single resolution emits several raw oracle events; `market_resolved` deduplicates them into one delivery per market. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `outcomes` | string\[] | No | Only fire when the winning outcome matches, e.g. `["Yes"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `exclude_shortterm_markets` | boolean | No | Exclude short-term `updown` markets from results | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "market_resolved", "event_slugs": ["us-presidential-election-2028"] } ``` ## Response ```json theme={null} { "event": "market_resolved", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "winning_outcome": "Yes", "settled_price": 1.0, "resolution_kind": "question_resolved", "question": "Will candidate X win the 2028 election?", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-candidate-x-win.png", "hash": "0x1f2e3d4c5b6a7988deadbeefcafebabe0011223344556677889900aabbccddee", "block": 19456789, "confirmed_at": 1713012000 } } ``` `resolution_kind` is one of `question_resolved`, `question_emergency_resolved`, `condition_resolution`, or `neg_risk_outcome_reported`. `settled_price` is present only for `QuestionResolved` (`1.0` = full YES, `0.0` = full NO, `0.5` = split). # Market Volume Milestone Source: https://docs.struct.to/websockets/alerts/market-volume-milestone Get notified when a market's trading volume crosses a USD milestone. **Event:** `market_volume_milestone` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a market's trading volume crosses a USD milestone. You must specify at least one timeframe to subscribe. ## Filters | Filter | Type | Required | Description | | ------------------- | ---------- | -------- | ------------------------------------------------------------------------------ | | `timeframes` | string\[] | Yes | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `milestone_amounts` | integer\[] | No | USD milestone amounts to watch, positive integers up to \$100M (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "market_volume_milestone", "timeframes": ["1h", "24h"], "milestone_amounts": [10000, 50000, 100000] } ``` ## Response ```json theme={null} { "event": "market_volume_milestone", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "timeframe": "24h", "milestone_usd": 50000, "current_volume_usd": 51230.80, "fees": 512.30, "txns": 134 } } ``` # Market Volume Spike Source: https://docs.struct.to/websockets/alerts/market-volume-spike Get notified when a market's volume exceeds its baseline by a configured ratio. **Event:** `market_volume_spike` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a market's volume exceeds its baseline by a configured ratio. The `spike_ratio` filter is required and defines the volume multiplier threshold. ## Filters | Filter | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `spike_ratio` | number | Yes | Volume multiplier threshold, must be greater than 1.0 (e.g. 2.0 = 2x baseline) | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `window_secs` | number | No | Snapshot baseline reset interval in seconds (1-600) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "market_volume_spike", "spike_ratio": 2.0, "timeframes": ["5m", "1h"] } ``` ## Response ```json theme={null} { "event": "market_volume_spike", "timestamp": 1775913505260, "data": { "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "question": "Will candidate X win the 2028 election?", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-candidate-x-win.png", "timeframe": "5m", "current_volume_usd": 24500.00, "snapshot_volume_usd": 11200.00, "delta_volume_usd": 13300.00, "spike_pct": 118.75, "txns": 63, "fees": 245.00 } } ``` # Oracle Events Source: https://docs.struct.to/websockets/alerts/oracle-events Typed UMA oracle lifecycle alerts for Polymarket markets. **Event:** `oracle_events` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires on every on-chain oracle event affecting a Polymarket market. Use `oracle_event_types` to restrict to specific kinds, and `condition_ids` to narrow by market. The `data` payload is a discriminated union keyed on `event_type`. ## Filters | Filter | Type | Required | Description | | -------------------- | ---------- | -------- | ------------------------------------------------- | | `oracle_event_types` | `string[]` | No | Restrict to specific event types (see list below) | | `condition_ids` | `string[]` | No | Restrict to specific markets | #### Event types Valid values for `oracle_event_types`: `AssertionMade`, `AssertionDisputed`, `AssertionSettled`, `RequestPrice`, `ProposePrice`, `DisputePrice`, `Settle`, `QuestionResolved`, `QuestionEmergencyResolved`, `QuestionReset`, `QuestionInitialized`, `QuestionPaused`, `QuestionUnpaused`, `QuestionFlagged`, `QuestionUnflagged`, `ConditionResolution`, `NegRiskOutcomeReported`. ## Subscribe ```json theme={null} { "op": "subscribe", "event": "oracle_events", "oracle_event_types": ["QuestionResolved", "ConditionResolution"], "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"] } ``` ## Response The `data` object is a discriminated union keyed on `event_type`. The `QuestionResolved` variant is shown below; full schemas for every variant are documented on the [Oracle Events room](/websockets/rooms/oracle-events) page. ```json theme={null} { "event": "oracle_events", "timestamp": 1743500000000, "data": { "event_type": "QuestionResolved", "id": "0xresolved123", "hash": "0xresolvedhash", "block": 65001400, "confirmed_at": 1743501400, "log_index": 2, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "settled_price": 1000000000000000000, "proposed_outcome": "Yes" } } ``` # Position Holder Metrics Source: https://docs.struct.to/websockets/alerts/position-holder-metrics Get notified when holder metrics update for a position. **Event:** `position_holder_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.025 credits per event Fires when holder metrics update for one of the positions you subscribe to, reporting the current holder count along with the aggregate balance and cost basis across all holders. The `position_ids` filter is required: you only receive metrics for the position token IDs you name. ## Filters | Filter | Type | Required | Description | | -------------- | ---------- | -------- | ------------------------------------- | | `position_ids` | `string[]` | Yes | Position token IDs to track (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "position_holder_metrics", "position_ids": ["21742633143463906290569050155826241533067272736897614950488156847949938836455"] } ``` ## Response ```json theme={null} { "event": "position_holder_metrics", "timestamp": 1775913505260, "data": { "ts": 1713052800, "block": 68554321, "position_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455", "holder_count": 1842, "total_balance": 5230000.0, "total_cost_basis": 2870000.00, "condition_holder_count": 3050, "event_holder_count": 9120 } } ``` # Position Metrics Source: https://docs.struct.to/websockets/alerts/position-metrics Get notified when a position's volume or price metrics cross a threshold. **Event:** `position_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a position's volume or price metrics cross a threshold. This event supports a wide range of filters for volume, fees, trader counts, and price or probability changes. ## Filters | Filter | Type | Required | Description | | ---------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `min_volume_usd` | number | No | Minimum volume in USD | | `max_volume_usd` | number | No | Maximum volume in USD | | `min_buy_usd` | number | No | Minimum buy volume in USD | | `min_sell_volume_usd` | number | No | Minimum sell volume in USD | | `min_fees` | number | No | Minimum fees in USD | | `min_txns` | number | No | Minimum transaction count | | `min_unique_traders` | number | No | Minimum unique trader count | | `min_price_change_pct` | number | No | Minimum price change percentage | | `min_probability_change_pct` | number | No | Minimum probability change percentage | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "position_metrics", "min_volume_usd": 500, "outcomes": ["Yes"], "timeframes": ["5m", "30m"] } ``` ## Response **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. ```json theme={null} { "event": "position_metrics", "timestamp": 1775913505260, "data": { "position_id": "0xabc123", "outcome": "Yes", "outcome_index": 0, "timeframe": "5m", "volume_usd": 3420.75, "buy_volume_usd": 2100.00, "sell_volume_usd": 1320.75, "shares_volume": 5260.0, "shares_buy_volume": 3230.0, "shares_sell_volume": 2030.0, "builder_usd_volume": 860.0, "builder_usd_buy_volume": 540.0, "builder_usd_sell_volume": 320.0, "builder_shares_volume": 1320.0, "builder_shares_buy_volume": 830.0, "builder_shares_sell_volume": 490.0, "fees": 34.20, "builder_fees": 8.6, "txns": 18, "buys": 12, "sells": 6, "builder_txns": 5, "builder_buys": 3, "builder_sells": 2, "unique_traders": 9, "unique_builder_traders": 3, "price_open": 0.62, "price_close": 0.65, "price_high": 0.67, "price_low": 0.60, "probability_open": 0.61, "probability_close": 0.64, "probability_high": 0.66, "probability_low": 0.59, "avg_trade_shares": 292.22, "avg_buy_shares": 269.17, "avg_sell_shares": 338.33 } } ``` Each payload includes builder-attributed counterparts for volume, fees, transactions, and unique traders, with separate buy and sell breakdowns. `avg_trade_shares`, `avg_buy_shares`, and `avg_sell_shares` report the average size of trades, buys, and sells in the window. # Position Volume Milestone Source: https://docs.struct.to/websockets/alerts/position-volume-milestone Get notified when a position's trading volume crosses a USD milestone. **Event:** `position_volume_milestone` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a position's trading volume crosses a USD milestone. You must specify at least one timeframe to subscribe. ## Filters | Filter | Type | Required | Description | | ------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `timeframes` | string\[] | Yes | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `milestone_amounts` | integer\[] | No | USD milestone amounts to watch (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "position_volume_milestone", "timeframes": ["1h"], "condition_ids": ["0x1234567890abcdef"], "milestone_amounts": [5000, 10000] } ``` ## Response ```json theme={null} { "event": "position_volume_milestone", "timestamp": 1775913505260, "data": { "position_id": "0xabc123", "condition_id": "0x1234567890abcdef", "outcome": "Yes", "outcome_index": 0, "timeframe": "1h", "milestone_usd": 10000, "current_volume_usd": 10540.30, "buy_volume_usd": 7200.50, "sell_volume_usd": 3339.80, "fees": 105.40, "txns": 42, "buys": 28, "sells": 14 } } ``` # Position Volume Spike Source: https://docs.struct.to/websockets/alerts/position-volume-spike Get notified when a position's volume exceeds its baseline by a configured ratio. **Event:** `position_volume_spike` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a position's volume exceeds its baseline by a configured ratio. The `spike_ratio` filter is required and defines the volume multiplier threshold. ## Filters | Filter | Type | Required | Description | | --------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spike_ratio` | number | Yes | Volume multiplier threshold, must be greater than 1.0 (e.g. 2.0 = 2x baseline) | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | | `window_secs` | number | No | Window reset interval in seconds (1-600) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "position_volume_spike", "spike_ratio": 2.5, "timeframes": ["5m", "30m"] } ``` ## Response ```json theme={null} { "event": "position_volume_spike", "timestamp": 1775913505260, "data": { "position_id": "0xabc123", "condition_id": "0x1234567890abcdef", "question": "Will the Fed cut rates in December?", "market_slug": "fed-decision-december-2028", "event_slug": "us-presidential-election-2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/example.png", "outcome": "Yes", "outcome_index": 0, "timeframe": "5m", "current_volume_usd": 8750.00, "snapshot_volume_usd": 3200.00, "delta_volume_usd": 5550.00, "spike_pct": 173.44, "txns": 28, "fees": 87.50 } } ``` # Price Spike Source: https://docs.struct.to/websockets/alerts/price-spike Get notified when a position's raw trade price changes rapidly. **Event:** `price_spike` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when a position's raw trade price changes rapidly. Configure the observation window and minimum change percentage to control sensitivity. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `tags` | string\[] | No | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500) | | `series_slugs` | string\[] | No | Restrict to markets in any of these series by slug, case-insensitive (max 500) | | `min_price` | number | No | Lower bound for both baseline and current price (0.0-1.0). Useful for excluding moves into resolution extremes | | `max_price` | number | No | Upper bound for both baseline and current price (0.0-1.0) | | `min_price_change_pct` | number | No | Minimum price change percentage (0-100) | | `min_txns` | integer | No | Minimum number of trades accumulated in the current window before a spike can fire | | `min_volume_usd` | number | No | Minimum USD volume accumulated in the current window before a spike can fire | | `spike_direction` | string | No | Direction filter: `up`, `down`, or `both`. Defaults to `up` when omitted | | `window_secs` | number | No | Observation window in seconds (1-600). Also acts as the cooldown between fires for the same position (minimum 30s) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term `updown` markets from results | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. ## Subscribe ```json theme={null} { "op": "subscribe", "event": "price_spike", "min_price_change_pct": 15, "spike_direction": "up", "window_secs": 120 } ``` ## Response ```json theme={null} { "event": "price_spike", "timestamp": 1775913505260, "data": { "position_id": "0xabc123", "condition_id": "0x1234567890abcdef", "question": "Will the Fed cut rates in December?", "market_slug": "fed-decision-december-2028", "event_slug": "us-presidential-election-2028", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/example.png", "outcome": "Yes", "outcome_index": 0, "previous_price": 0.52, "current_price": 0.68, "spike_direction": "up", "spike_pct": 30.77 } } ``` # Price Threshold Source: https://docs.struct.to/websockets/alerts/price-threshold Get notified when an outcome's price crosses a target level. **Event:** `price_threshold` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Get notified when an outcome's price (implied probability) crosses a target level. It is edge-triggered: it fires once on the crossing, not on every trade that stays past the level. Set `min_price` for an upward target, `max_price` for a downward target, or both to watch from either side. ## Filters | Filter | Type | Required | Description | | --------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `min_price` | number | Yes\* | Upward target, 0.0-1.0. Fire when the price crosses up to ≥ this value (e.g. 0.75). \*At least one of `min_price` or `max_price` is required. | | `max_price` | number | Yes\* | Downward target, 0.0-1.0. Fire when the price crosses down to ≤ this value (e.g. 0.25). \*At least one of `min_price` or `max_price` is required. | | `one_shot` | boolean | No | Unsubscribe after the first delivery. Requires `position_ids` or `condition_ids` | | `fire_if_already_past` | boolean | No | Fire immediately if the first observed price is already past the target | | `condition_ids` | string\[] | No | Restrict to specific conditions (max 500) | | `position_ids` | string\[] | No | Restrict to specific positions (max 500) | | `outcomes` | string\[] | No | Filter by outcome name, e.g. `["Yes", "No"]` (max 500). Note that multiple choice and esports markets often have non-standard outcome names (e.g. team names). | | `position_outcome_indices` | number\[] | No | Filter by outcome index: 0 or 1 (max 500) | | `event_slugs` | string\[] | No | Restrict to specific events (max 500) | | `tags` | string\[] | No | Restrict to markets carrying any of these tags or category names, case-insensitive (max 500) | | `series_slugs` | string\[] | No | Restrict to markets in any of these series by slug, case-insensitive (max 500) | | `exclude_shortterm_markets` | boolean | No | Exclude short-term `updown` markets from results | **Scope by market taxonomy.** `tags` matches a market's own tags **or** its category, given as the display label shown on Polymarket (for example `"Sports"`, `"Politics"`, or `"FIFA World Cup"`), not a slug. `series_slugs` matches the market's parent series by slug (for example `"nba-finals"`). Both are case-insensitive, accept up to 500 values each, and an empty or omitted list applies no taxonomy restriction. ## Subscribe Fire when a specific market's YES outcome crosses up through 75%: ```json theme={null} { "op": "subscribe", "event": "price_threshold", "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_price": 0.75 } ``` ## Response ```json theme={null} { "event": "price_threshold", "timestamp": 1775913505260, "data": { "trader": "0x9d84cef98b41a5b88dafb55c05e9a7e0f1a4f4d6", "taker": "0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e", "position_id": "452312848583266388373324160190187140051835877600158453279131187530910662656", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "Yes", "outcome_index": 0, "question": "Will candidate X win the 2028 election?", "market_slug": "will-candidate-x-win", "event_slug": "us-presidential-election-2028", "trade_id": "0x9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b", "hash": "0x1f2e3d4c5b6a7988deadbeefcafebabe0011223344556677889900aabbccddee", "block": 19456789, "confirmed_at": 1713012000, "amount_usd": 5000.00, "shares_amount": 5263.16, "fee": 50.00, "side": "Buy", "previous_price": 0.72, "price": 0.76, "probability": 0.76, "direction": "up", "threshold": 0.75 } } ``` **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. # Tag Metrics Source: https://docs.struct.to/websockets/alerts/tag-metrics Get notified when a tag's aggregated volume or transaction metrics cross a threshold. **Event:** `tag_metrics` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Get notified when a tag's aggregated metrics (volume, fees, transactions, unique traders) cross a configured threshold. Alerts include a builder-attributed breakdown so you can monitor builder-routed activity separately. ## Filters | Filter | Type | Required | Description | | -------------------- | --------- | -------- | ------------------------------------------------------------------------------ | | `tags` | string\[] | No | Restrict to specific tags by label or slug (max 500) | | `min_volume_usd` | number | No | Minimum aggregated tag volume in USD | | `max_volume_usd` | number | No | Maximum aggregated tag volume in USD | | `min_fees` | number | No | Minimum fees in USD | | `min_txns` | number | No | Minimum transaction count | | `min_unique_traders` | number | No | Minimum unique trader count | | `timeframes` | string\[] | No | Timeframe windows: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "tag_metrics", "tags": ["Politics"], "min_volume_usd": 10000, "min_txns": 10, "timeframes": ["1h"] } ``` ## Response **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. ```json theme={null} { "event": "tag_metrics", "timestamp": 1743500000000, "data": { "tag": "politics", "timeframe": "1h", "volume_usd": 120000.0, "shares_volume": 148000.0, "builder_usd_volume": 30000.0, "builder_shares_volume": 37000.0, "fees": 600.0, "builder_fees": 150.0, "txns": 740, "builder_txns": 185, "unique_traders": 210, "unique_builder_traders": 54 } } ``` # Trader Category PnL Source: https://docs.struct.to/websockets/alerts/trader-category-pnl Get notified when a trader's per-category PnL crosses a configured threshold. **Event:** `trader_category_pnl` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires when a trader's profit and loss aggregated across all markets in a given category (for example politics, sports, or crypto) crosses the configured thresholds. This is useful for tracking how a wallet performs within a specific category rather than across its whole portfolio or a single market. ## Filters | Filter | Type | Required | Description | | ---------------------- | ---------- | -------- | ---------------------------------------------------------- | | `traders` | `string[]` | No | Specific trader wallet addresses (max 500) | | `categories` | `string[]` | No | Restrict to specific categories (max 500) | | `min_realized_pnl_usd` | `number` | No | Minimum realized PnL in USD | | `max_realized_pnl_usd` | `number` | No | Maximum realized PnL in USD | | `min_volume_usd` | `number` | No | Minimum total volume in USD | | `max_volume_usd` | `number` | No | Maximum total volume in USD | | `min_buy_usd` | `number` | No | Minimum buy volume in USD | | `min_sell_volume_usd` | `number` | No | Minimum sell volume in USD | | `min_win_rate` | `number` | No | Minimum win rate percentage (0.0 to 100.0) | | `min_markets_traded` | `number` | No | Minimum number of markets traded within the category | | `timeframes` | `string[]` | No | Timeframe windows: `1d`, `7d`, `30d`, `lifetime` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_category_pnl", "traders": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "categories": ["politics"], "min_realized_pnl_usd": 2000, "min_markets_traded": 3 } ``` ## Response ```json theme={null} { "event": "trader_category_pnl", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "category": "politics", "timeframe": "7d", "realized_pnl_usd": 8500.25, "total_pnl_usd": 9100.00, "markets_in_category": 12, "markets_traded": 5, "outcomes_traded": 8, "total_buys": 45, "total_sells": 30, "total_redemptions": 10, "total_merges": 3, "total_splits": 1, "total_volume_usd": 75000.00, "buy_usd": 50000.00, "sell_usd": 15000.00, "redemption_usd": 8000.00, "merge_usd": 2000.00, "split_volume_usd": 500.00, "convert_collateral_usd": 1200.00, "total_shares_bought": 120000.00, "markets_won": 4, "markets_lost": 1, "market_win_rate_pct": 80.00, "avg_hold_time_seconds": 432000.0, "best_trade_pnl_usd": 3200.00, "best_trade_condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "worst_trade_pnl_usd": -450.00, "worst_trade_condition_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b", "total_wins_usd": 9800.00, "total_losses_usd": -1300.00, "avg_win_usd": 2450.00, "avg_loss_usd": -1300.00, "profit_factor": 7.54, "total_fees": 150.00, "first_trade_at": 1712966400, "last_trade_at": 1713052800 } } ``` # Trader First Trade Source: https://docs.struct.to/websockets/alerts/trader-first-trade Get notified when a trader executes their first-ever trade on Polymarket. **Event:** `trader_first_trade` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires the first time a wallet address executes a trade on Polymarket. This is useful for tracking new market participants or detecting when monitored wallets become active for the first time. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ------------------------------------------------------ | | `wallet_addresses` | `string[]` | No | Restrict to specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `min_usd_value` | `number` | No | Minimum trade size in USD | | `min_price` | `number` | No | Minimum probability threshold (0.0 to 1.0) | | `max_price` | `number` | No | Maximum probability threshold (0.0 to 1.0) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_first_trade", "wallet_addresses": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "min_usd_value": 100 } ``` ## Response ```json theme={null} { "event": "trader_first_trade", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "taker": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "position_id": "12345", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "Yes", "outcome_index": 0, "question": "Will the Virginia constitutional amendment referendum fail to pass?", "market_slug": "will-the-virginia-constitutional-amendment-referendum-fail-to-pass", "event_slug": "virginia-constitutional-amendment-referendum", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-the-virginia-constitutional-amendment-referendum-fail-to-pass.png", "trade_id": "abc123", "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "block": 58321045, "confirmed_at": 1713012000, "amount_usd": 199, "shares_amount": 970.73, "fee": 0.40, "side": "Buy", "price": 0.2048, "exchange": "Polymarket", "trade_type": "OrderFilled" } } ``` # Trader Global PnL Source: https://docs.struct.to/websockets/alerts/trader-global-pnl Get notified when a trader's global PnL crosses a configured threshold. **Event:** `trader_global_pnl` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires when a trader's aggregated global profit and loss crosses the configured thresholds. This event covers the trader's entire Polymarket portfolio, making it useful for monitoring overall performance of specific wallets. ## Filters | Filter | Type | Required | Description | | ---------------------- | ---------- | -------- | ---------------------------------------------------------- | | `traders` | `string[]` | No | Specific trader wallet addresses (max 500) | | `min_realized_pnl_usd` | `number` | No | Minimum realized PnL in USD | | `max_realized_pnl_usd` | `number` | No | Maximum realized PnL in USD | | `min_volume_usd` | `number` | No | Minimum total volume in USD | | `min_win_rate` | `number` | No | Minimum win rate percentage (0.0 to 100.0) | | `max_volume_usd` | `number` | No | Maximum total volume in USD | | `min_buy_usd` | `number` | No | Minimum buy volume in USD | | `min_sell_volume_usd` | `number` | No | Minimum sell volume in USD | | `min_markets_traded` | `number` | No | Minimum number of markets traded | | `timeframes` | `string[]` | No | Timeframe windows: `1d`, `7d`, `30d`, `lifetime` (max 500) | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_global_pnl", "traders": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "min_realized_pnl_usd": 5000, "min_win_rate": 60 } ``` ## Response ```json theme={null} { "event": "trader_global_pnl", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "timeframe": "lifetime", "realized_pnl_usd": 12500.75, "events_traded": 45, "markets_traded": 82, "total_buys": 320, "total_sells": 180, "total_redemptions": 55, "total_merges": 12, "total_volume_usd": 250000.00, "buy_volume_usd": 175000.00, "sell_volume_usd": 50000.00, "redemption_volume_usd": 20000.00, "merge_volume_usd": 5000.00, "markets_won": 52, "markets_lost": 30, "market_win_rate_pct": 63.41, "avg_pnl_per_market": 152.45, "avg_pnl_per_trade": 25.00, "avg_hold_time_seconds": 604800.0, "total_fees": 500.00, "best_trade_pnl_usd": 3200.00, "best_trade_condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "first_trade_at": 1736929800, "last_trade_at": 1781620700 } } ``` # Trader Market PnL Source: https://docs.struct.to/websockets/alerts/trader-market-pnl Get notified when a trader's per-market PnL crosses a configured threshold. **Event:** `trader_market_pnl` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires when a trader's profit and loss in a specific market crosses the configured thresholds. Unlike `trader_global_pnl`, this event is scoped to individual markets, allowing you to track performance on a per-market basis. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ---------------------------------------------------------- | | `traders` | `string[]` | No | Specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `min_realized_pnl_usd` | `number` | No | Minimum realized PnL in USD | | `max_realized_pnl_usd` | `number` | No | Maximum realized PnL in USD | | `min_buy_usd` | `number` | No | Minimum buy volume in USD | | `min_volume_usd` | `number` | No | Minimum total volume in USD | | `max_volume_usd` | `number` | No | Maximum total volume in USD | | `min_sell_volume_usd` | `number` | No | Minimum sell volume in USD | | `timeframes` | `string[]` | No | Timeframes to track (valid: `1d`, `7d`, `30d`, `lifetime`) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_market_pnl", "traders": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "min_realized_pnl_usd": 1000, "exclude_shortterm_markets": true } ``` ## Response ```json theme={null} { "event": "trader_market_pnl", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "event_slug": "2026-us-midterm-elections", "timeframe": "7d", "outcomes_traded": 2, "total_buys": 12, "total_sells": 8, "total_redemptions": 3, "total_merges": 1, "buy_usd": 10000.00, "sell_usd": 3500.00, "redemption_usd": 1200.00, "merge_usd": 300.00, "realized_pnl_usd": 3200.50, "winning_outcomes": 2, "total_fees": 30.00, "first_trade_at": 1712966400, "last_trade_at": 1713052800 } } ``` # Trader New Market Source: https://docs.struct.to/websockets/alerts/trader-new-market Get notified when a trader places their first trade in a specific market. **Event:** `trader_new_market` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires when a trader executes their first trade in a market they have not previously participated in. This is useful for tracking when known wallets expand into new markets or when specific markets attract new participants. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ------------------------------------------------------ | | `wallet_addresses` | `string[]` | No | Restrict to specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `min_usd_value` | `number` | No | Minimum trade size in USD | | `min_price` | `number` | No | Minimum probability threshold (0.0 to 1.0) | | `max_price` | `number` | No | Maximum probability threshold (0.0 to 1.0) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_new_market", "wallet_addresses": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "min_usd_value": 500, "exclude_shortterm_markets": true } ``` ## Response ```json theme={null} { "event": "trader_new_market", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "taker": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "position_id": "67890", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "Yes", "outcome_index": 0, "question": "Will Bitcoin reach $150k by July 2026?", "market_slug": "will-bitcoin-reach-150k-by-july-2026", "event_slug": "bitcoin-price-milestones", "trade_id": "def456", "hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", "block": 58321100, "confirmed_at": 1713016200, "amount_usd": 2500, "shares_amount": 3846.15, "fee": 5.00, "side": "Buy", "price": 0.65, "probability": 0.65, "exchange": "Polymarket", "trade_type": "OrderFilled" } } ``` **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. # Trader New Trade Source: https://docs.struct.to/websockets/alerts/trader-new-trade Get notified on every order-filled trade with optional filters. **Event:** `trader_new_trade` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Fires on every order-filled trade that matches the configured filters. Unlike `trader_first_trade` or `trader_new_market`, this event triggers for all qualifying trades, making it suitable for continuous trade monitoring. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- | | `wallet_addresses` | `string[]` | No | Restrict to specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `trade_types` | `string[]` | No | Restrict to specific fill trade types (e.g. `OrderFilled`, `OrdersMatched`) (max 500) | | `min_usd_value` | `number` | No | Minimum trade size in USD | | `min_price` | `number` | No | Minimum probability threshold (0.0 to 1.0) | | `max_price` | `number` | No | Maximum probability threshold (0.0 to 1.0) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_new_trade", "wallet_addresses": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_usd_value": 50 } ``` ## Response ```json theme={null} { "event": "trader_new_trade", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "taker": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "position_id": "24680", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "No", "outcome_index": 1, "question": "Will the Fed cut rates in June 2026?", "market_slug": "will-the-fed-cut-rates-in-june-2026", "event_slug": "fed-rate-decisions-2026", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-the-fed-cut-rates-in-june-2026.png", "trade_id": "ghi789", "hash": "0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba", "block": 58321200, "confirmed_at": 1713019500, "amount_usd": 750, "shares_amount": 1875.00, "fee": 1.50, "side": "Sell", "price": 0.40, "probability": 0.60, "exchange": "Polymarket", "trade_type": "OrderFilled" } } ``` **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. # Trader PnL Exits Source: https://docs.struct.to/websockets/alerts/trader-pnl-exits Get notified each time a trader closes a position, with the realized result. **Event:** `trader_pnl_exits` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.025 credits per event Fires once each time a tracked trader closes a position, emitting a single exit marker with the realized result. Each marker is designed to be overlaid on a PnL chart so you can see exactly where a trader entered and left a market. ## Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ------------------------------------------ | | `traders` | `string[]` | No | Specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | ## Exit reasons Each marker carries a `reason` describing how the position closed: | Reason | Meaning | | --------------- | ----------------------------------------------------- | | `resolved_win` | Held to market resolution and won by verdict | | `resolved_loss` | Held to market resolution and lost by verdict | | `sold_win` | Closed before resolution with a positive realized PnL | | `sold_loss` | Closed before resolution with a negative realized PnL | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_pnl_exits", "traders": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"] } ``` ## Response ```json theme={null} { "event": "trader_pnl_exits", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "position_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "event_slug": "2026-us-midterm-elections", "market_slug": "will-candidate-x-win", "title": "Will Candidate X win?", "question": "Will Candidate X win the 2026 election?", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/example.png", "outcome": "Yes", "outcome_index": 0, "pnl_usd": 1700.00, "pnl_pct": 56.67, "cost_basis_usd": 3000.00, "reason": "resolved_win", "block": 68554321, "ts": 1713052800 } } ``` # Trader Position Resolved Source: https://docs.struct.to/websockets/alerts/trader-position-resolved Get notified when a trader's position resolves in a market. **Event:** `trader_position_resolved` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.1 credits per event Fires when a position held by a tracked trader reaches resolution, meaning the owning market settled and a win or loss verdict is known. The payload is the full position row with the `won` flag set. Use the `won_only` filter to receive only winning resolutions, only losing resolutions, or both. ## Filters | Filter | Type | Required | Description | | ----------------- | ---------- | -------- | ------------------------------------------------------------ | | `traders` | `string[]` | No | Specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `outcome_indexes` | `number[]` | No | Restrict to specific outcome indexes | | `won_only` | `boolean` | No | `true` for wins only, `false` for losses only, omit for both | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_position_resolved", "traders": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "won_only": true } ``` ## Response ```json theme={null} { "event": "trader_position_resolved", "timestamp": 1775913505260, "data": { "trader": "0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec", "position_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "market_slug": "will-candidate-x-win", "event_slug": "2026-us-midterm-elections", "title": "Will Candidate X win?", "question": "Will Candidate X win the 2026 election?", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/example.png", "outcome": "Yes", "outcome_index": 0, "open": false, "won": true, "total_buys": 8, "total_sells": 2, "converted_count": 0, "converted_shares_gained": 0.0, "converted_shares_lost": 0.0, "total_shares_bought": 5000.0, "total_shares_sold": 1000.0, "total_buy_usd": 3000.00, "total_sell_usd": 700.00, "redemption_usd": 4000.00, "merge_usd": 0.00, "avg_entry_price": 0.60, "avg_exit_price": 0.70, "avg_price": 0.62, "realized_pnl_usd": 1700.00, "realized_pnl_pct": 56.67, "total_fees": 25.00, "first_trade_at": 1712966400000, "last_trade_at": 1713052800000, "current_price": 1.0, "current_shares_balance": 4000.0, "current_value": 4000.00, "last_traded_price": 0.70, "end_date": 1781620700, "is_neg_risk": false, "redeemable": true, "mergeable": false, "dirty_kinds": ["position_resolved"] } } ``` # Trader Trade Event Source: https://docs.struct.to/websockets/alerts/trader-trade-event Typed trade-event alert covering every confirmed prediction trade variant. **Event:** `trader_trade_event` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Fires on every confirmed prediction trade. Unlike `trader_new_trade`, this alert delivers a typed discriminated-union payload that covers all trade variants (`OrderFilled`, `Redemption`, `Merge`, `Split`, `PositionsConverted`, `Cancelled`, oracle lifecycle, and more) with the correct fields per variant. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ---------------------------------------------------------------------------------------- | | `wallet_addresses` | `string[]` | No | Restrict to specific trader wallet addresses (max 500) | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `trade_types` | `string[]` | No | Restrict to specific trade variants (e.g. `OrderFilled`, `Redemption`, `Merge`, `Split`) | | `min_usd_value` | `number` | No | Minimum trade size in USD | | `min_price` | `number` | No | Minimum probability threshold (0.0 to 1.0) | | `max_price` | `number` | No | Maximum probability threshold (0.0 to 1.0) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_trade_event", "wallet_addresses": ["0x37BA57e2942A57F05B5E4BCFD4d027845cFC40ec"], "condition_ids": ["0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b"], "min_usd_value": 50, "exclude_shortterm_markets": true } ``` ## Response The `data` object is a discriminated union keyed on `trade_type`. The `OrderFilled` variant is shown below; `Redemption`, `Merge`, `Split`, `PositionsConverted`, `Cancelled`, and the oracle lifecycle variants follow the same shapes documented for the [Trades room](/websockets/rooms/trades). ```json theme={null} { "event": "trader_trade_event", "timestamp": 1743500000000, "data": { "trade_type": "OrderFilled", "id": "0xabc123def456", "hash": "0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba", "block": 65000000, "confirmed_at": 1700000000, "log_index": 0, "block_index": 0, "order_hash": "0x1111111111111111111111111111111111111111111111111111111111111111", "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, "taker": "0x0000000000000000000000000000000000000000", "side": "Buy", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "position_id": "452312848583266388373324160190187140051835877600158453279131187530910662656", "outcome": "Yes", "outcome_index": 0, "question": "Will the Fed cut rates in June 2026?", "slug": "will-the-fed-cut-rates-in-june-2026", "event_slug": "fed-rate-decisions-2026", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-the-fed-cut-rates-in-june-2026.png", "usd_amount": 25.0, "shares_amount": 50.0, "price": 0.5, "probability": 0.5, "fee": 0.025, "fee_shares": 0.0, "fee_pct": 0.1, "exchange": "CTFExchange", "builder_code": "0x0000000000000000000000000000000000000000000000000000000000000000", "builder_fee": 0.0 } } ``` `builder_code` is the lower-cased `0x...` bytes32 identifier of the integrator that brokered the order (CLOB v2 only). `0x0000…` means the order was placed directly through Polymarket. Both `builder_code` and `builder_fee` are omitted on v1 trades. See [Fetching trades by builder code](/guides/fetching-trades-by-builder-code). # Trader Whale Trade Source: https://docs.struct.to/websockets/alerts/trader-whale-trade Get notified when a trade exceeds a configured size threshold. **Event:** `trader_whale_trade` \ **Endpoint:** `wss://api.struct.to/ws/alerts` \ **Cost:** 0.2 credits per event Fires when a trade exceeds the configured USD size threshold. This is the primary event for tracking large trades across Polymarket, commonly used to follow whale activity and detect significant market-moving positions. ## Filters | Filter | Type | Required | Description | | --------------------------- | ---------- | -------- | ------------------------------------------------------ | | `wallet_addresses` | `string[]` | No | Restrict to specific trader wallet addresses (max 500) | | `min_usd_value` | `number` | Yes | Minimum trade size in USD | | `condition_ids` | `string[]` | No | Restrict to specific markets (max 500) | | `event_slugs` | `string[]` | No | Restrict to specific events (max 500) | | `min_price` | `number` | No | Minimum probability threshold (0.0 to 1.0) | | `max_price` | `number` | No | Maximum probability threshold (0.0 to 1.0) | | `exclude_shortterm_markets` | `boolean` | No | Exclude short-term "updown" markets | ## Subscribe ```json theme={null} { "op": "subscribe", "event": "trader_whale_trade", "min_usd_value": 10000, "exclude_shortterm_markets": true } ``` ## Response ```json theme={null} { "event": "trader_whale_trade", "timestamp": 1775913505260, "data": { "trader": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "taker": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "position_id": "13579", "condition_id": "0x4fec624c0ff2bfae89956cebd6fbc9c58f995f824382dc587dc5a32a4b15940b", "outcome": "Yes", "outcome_index": 0, "question": "Will ETH reach $10k by December 2026?", "market_slug": "will-eth-reach-10k-by-december-2026", "event_slug": "eth-price-milestones", "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-eth-reach-10k-by-december-2026.png", "trade_id": "jkl012", "hash": "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", "block": 58321500, "confirmed_at": 1713021600, "amount_usd": 50000, "shares_amount": 71428.57, "fee": 100.00, "side": "Buy", "price": 0.70, "probability": 0.70, "exchange": "Polymarket", "trade_type": "OrderFilled" } } ``` **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. # Compression Source: https://docs.struct.to/websockets/compression Opt into zstd compression on the Rooms websocket for high-throughput sockets. High-throughput sockets can opt into **zstd** compression. It's negotiated once at the WebSocket upgrade and is **connection-scoped**: it applies to every room subscription on that connection, and can't be changed without reconnecting. When enabled, room envelopes arrive as **binary frames of zstd-compressed JSON** rather than text frames. Everything else about the protocol is unchanged, only the wire format of inbound messages differs. ## Enabling compression Request compression at the handshake in one of two ways: | Method | How | When to use | | ------ | ------------------------ | ------------------------------------------------------------------------------ | | Header | `X-Ws-Compression: zstd` | Preferred. Any client that can set upgrade headers. | | Query | `?compression=zstd` | Fallback for browsers, which can't set headers on the `WebSocket` constructor. | ``` wss://api.struct.to/ws?api-key=YOUR_API_KEY&compression=zstd ``` Omit both the header and the query parameter for the default uncompressed text frames. `zstd` is the only supported value; an unsupported value fails the upgrade with `400`. Subscribe-message compression is not used, only the handshake matters. The header is preferred because it keeps the compression choice out of the URL and logs. Use the query parameter only when your client can't set upgrade headers, as is the case for the browser `WebSocket` constructor. If you set both, they must agree. ## Decoding frames A compressed connection delivers each room envelope as a binary frame. Decompress the bytes with zstd, then `JSON.parse` the resulting UTF-8 string exactly as you would an uncompressed text frame. ```typescript Node (ws + fzstd) theme={null} import WebSocket from "ws"; import { decompress } from "fzstd"; const ws = new WebSocket("wss://api.struct.to/ws?api-key=YOUR_API_KEY", { headers: { "X-Ws-Compression": "zstd" }, }); const decoder = new TextDecoder(); ws.on("message", (data: Buffer, isBinary: boolean) => { const raw = isBinary ? decoder.decode(decompress(new Uint8Array(data))) : data.toString(); const msg = JSON.parse(raw); console.log(msg.type, msg.message); }); ``` ```javascript Browser (query fallback + fzstd) theme={null} import { decompress } from "fzstd"; const ws = new WebSocket("wss://api.struct.to/ws?api-key=YOUR_API_KEY&compression=zstd"); ws.binaryType = "arraybuffer"; const decoder = new TextDecoder(); ws.onmessage = (event) => { const raw = event.data instanceof ArrayBuffer ? decoder.decode(decompress(new Uint8Array(event.data))) : event.data; const msg = JSON.parse(raw); console.log(msg.type, msg.message); }; ``` ```python Python (websockets + zstandard) theme={null} import asyncio import json import zstandard import websockets async def main(): uri = "wss://api.struct.to/ws?api-key=YOUR_API_KEY" dctx = zstandard.ZstdDecompressor() async with websockets.connect(uri, additional_headers={"X-Ws-Compression": "zstd"}) as ws: async for frame in ws: raw = dctx.decompress(frame) if isinstance(frame, bytes) else frame msg = json.loads(raw) print(msg["type"], msg.get("message")) asyncio.run(main()) ``` Browsers have no native zstd decoder (`DecompressionStream` supports only gzip and deflate), so a browser client needs a small library such as [`fzstd`](https://www.npmjs.com/package/fzstd) to decompress frames. The TypeScript SDK handles this for you. Branch on the frame type rather than assuming every frame is binary. Text control frames such as the `{"type":"pong"}` keepalive reply still arrive as text, so a `typeof data === "string"` (or `isBinary` / `instanceof ArrayBuffer`) check keeps both paths working on the same connection. ## Confirming the negotiated mode Each room's `*_subscribe_response` echoes a `compression` field reporting whether zstd delivery is active for the connection, so you can assert the negotiation succeeded without inspecting frame types. ```json theme={null} { "type": "trade_stream_subscribe_response", "message": { "compression": "zstd", "subscribe_all": false } } ``` ## When to use it Compression trades a little CPU on both ends for lower bandwidth. It pays off most on high-volume subscriptions, verbose JSON payloads (metrics and order-book rooms), [firehose subscriptions](/websockets/firehose), and bandwidth-constrained or metered clients. For a handful of narrowly filtered rooms the savings are marginal, so leave it off unless you're moving real volume. The [dashboard websockets playground](https://www.struct.to/dashboard/websockets) has a **zstd compression** toggle on the connection card, so you can watch compressed delivery end to end before wiring it into your own client. ## Next steps * [Firehose subscriptions](/websockets/firehose) — the highest-volume streams, where compression matters most. * [WebSocket Pricing](/websockets/pricing) — per-message rates (compression doesn't change billing; you're billed per message, not per byte). * [Getting Started](/websockets/getting-started) — the full Rooms protocol. # Firehose Source: https://docs.struct.to/websockets/firehose Subscribe to the full, unfiltered stream of any room with subscribe_all. Most rooms accept a `subscribe_all` boolean on the subscribe message to receive **every** update on that stream instead of a targeted subset. Reach for it when you want the full feed and intend to filter or fan out downstream, for example to warm a cache, drive an analytics pipeline, or mirror a room into your own store. ## Enabling firehose Set `subscribe_all: true` on the `subscribe` message after joining the room: ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_order_book", "message": { "action": "subscribe", "subscribe_all": true } } } ``` In firehose mode, `polymarket_order_book` conflates the full stream into `order_book_batch` envelopes emitted every 50ms rather than a message per book change, so you receive periodic batched snapshots instead of a raw per-update feed. Behaviour is consistent across rooms: * **Targeting filters are ignored** while `subscribe_all` is set. This is the id/slug/address selector each room uses to scope the stream, for example `condition_ids`, `event_slugs`, `position_ids`, `tags`, `traders`, or `wallets`. * **Secondary filters still apply.** Modifiers that shape the payload rather than pick a target keep working, for example `timeframes` on the metrics rooms, `reasons` on `polymarket_trader_exit_markers`, and the `include_*` flags on `polymarket_accounts`. * **Some rooms firehose implicitly.** `polymarket_trades` and `polymarket_oracle_events` treat an empty filter set as a firehose, so `subscribe_all: true` there is just the explicit form of "no filters". ## Per-room behaviour | Room | Targeting filter (ignored) | Filters that still apply | Notes | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------- | | [`polymarket_trades`](/websockets/rooms/trades) | `condition_ids`, `market_slugs`, `event_slugs`, `position_ids`, `traders`, `trade_types` | `status` | Empty filters imply firehose. | | [`polymarket_oracle_events`](/websockets/rooms/oracle-events) | all filters | `status`, `rejected` | Empty filters imply firehose. | | [`polymarket_asset_window_updates`](/websockets/rooms/asset-window-updates) | `asset_symbols`, `timeframes` | — | | | [`polymarket_market_metrics`](/websockets/rooms/market-metrics) | `condition_ids` | `timeframes` | | | [`polymarket_event_metrics`](/websockets/rooms/event-metrics) | `event_slugs` | `timeframes` | | | [`polymarket_tag_metrics`](/websockets/rooms/tag-metrics) | `tags` | `timeframes` | | | [`polymarket_position_metrics`](/websockets/rooms/position-metrics) | `position_ids` | `timeframes` | | | [`polymarket_holder_metrics`](/websockets/rooms/holder-metrics) | `position_ids`, `condition_ids`, `event_slugs` | — | | | [`polymarket_trader_exit_markers`](/websockets/rooms/trader-exit-markers) | `traders` | `reasons` | | | [`polymarket_accounts`](/websockets/rooms/accounts) | `wallets` | `include_*` flags | | | [`polymarket_order_book`](/websockets/rooms/order-book) | `condition_ids`, `position_ids` | — | Delivered as conflated `order_book_batch` envelopes every 50ms. | | [`polymarket_clob_rewards`](/websockets/rooms/clob-rewards) | `condition_ids` | — | | | [Liquidity rooms](/websockets/rooms/liquidity) | `id` filters | — | Omitting filters also firehoses. | Firehose subscriptions are billed per message like any other, and the full stream can be high volume. Check the room's rate on [WebSocket Pricing](/websockets/pricing) before turning it on, and prefer server-side targeting filters when you only need a slice. ## Confirming the subscription The room's `*_subscribe_response` echoes `subscribe_all` so you can confirm the firehose is active: ```json theme={null} { "type": "order_book_stream_subscribe_response", "message": { "subscribe_all": true } } ``` ## Practical tips * **Compress the firehose.** The full stream is exactly where [zstd compression](/websockets/compression) earns its keep. Turn it on at the handshake before subscribing. * **Filter downstream, not by reconnecting.** A firehose plus server-side fan-out is cheaper to operate than churning targeted subscriptions, but you still pay per message, so only firehose what you'll actually consume. * **Order book is pre-batched.** `polymarket_order_book` conflates its firehose into `order_book_batch` envelopes every 50ms, so you get periodic batched snapshots rather than a message per book change. The [dashboard websockets playground](https://www.struct.to/dashboard/websockets) exposes `subscribe_all` as a per-room toggle, so you can preview a room's firehose volume and payload before committing to it in code. ## Next steps * [Compression](/websockets/compression) — zstd delivery for the high-volume streams firehose produces. * [Rooms](/websockets/rooms/trades) — every stream and its filters. * [WebSocket Pricing](/websockets/pricing) — per-message rates per room. # Getting Started Source: https://docs.struct.to/websockets/getting-started Stream real-time Polymarket data over websockets. ## Overview Struct's websocket API streams real-time Polymarket data directly to your application without polling REST. Connect once, subscribe to any number of **rooms**, and receive live updates for trades, prices, metrics, positions, PnL, order books, and more. Websockets are ideal for trading UIs, dashboards, and agents that need low-latency access to what's happening on Polymarket right now. Every metric stream is backed by the same pre-computed materialised rows the REST API serves, so push values are consistent with what you'd pull on demand. ## Endpoints There are two websocket endpoints: | Endpoint | Purpose | | ------------------------------- | ------------------------------------------------------------------------ | | `wss://api.struct.to/ws` | **Rooms**: subscribe to data streams by topic (trades, prices, metrics). | | `wss://api.struct.to/ws/alerts` | **Alerts**: webhook-style events pushed over the same connection. | This guide covers the Rooms endpoint. See [Alerts](/websockets/alerts/getting-started) for the alerts endpoint. ## Authentication Authenticate by appending your API key as a query parameter: ``` wss://api.struct.to/ws?api-key=YOUR_API_KEY ``` For browser or mobile clients, use a JWT public key and add a `token` parameter. See [Authentication](/introduction/authentication) for both flows. ## Compression High-throughput sockets can opt into **zstd** compression, negotiated once at the WebSocket upgrade with the `X-Ws-Compression: zstd` header or the `?compression=zstd` query fallback. When enabled, room envelopes arrive as binary frames of zstd-compressed JSON. See [Compression](/websockets/compression) for decode examples and client support. ## Your first connection Sign up at [struct.to/dashboard](https://struct.to/dashboard) and create an organisation. Open the [API Keys](https://struct.to/dashboard) page in your dashboard and create a new key. Copy the value somewhere safe; you won't be able to view it again. See [Authentication](/introduction/authentication) for key types, JWT public keys, and rotation. Use any WebSocket client to connect. The example below connects, subscribes to the Trades room for a specific market, and logs messages as they arrive. ```typescript SDK theme={null} import { StructWebSocket } from "@structbuild/sdk"; const ws = new StructWebSocket({ apiKey: "YOUR_API_KEY" }); await ws.connect(); await ws.subscribe("polymarket_trades", { condition_ids: ["0xabc..."] }); ws.on("trade_stream_update", (event) => { console.log(event.condition_id, event.price, event.side); }); ``` ```javascript Raw WebSocket theme={null} const ws = new WebSocket("wss://api.struct.to/ws?api-key=YOUR_API_KEY"); ws.onopen = () => { ws.send(JSON.stringify({ type: "join_room", payload: { room_id: "polymarket_trades" }, })); ws.send(JSON.stringify({ type: "room_message", payload: { room_id: "polymarket_trades", message: { action: "subscribe", condition_ids: ["0xabc..."] }, }, })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log(msg.type, msg.message); }; ``` ```python Python theme={null} import asyncio import json import websockets async def main(): uri = "wss://api.struct.to/ws?api-key=YOUR_API_KEY" async with websockets.connect(uri) as ws: await ws.send(json.dumps({ "type": "join_room", "payload": {"room_id": "polymarket_trades"}, })) await ws.send(json.dumps({ "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "condition_ids": ["0xabc..."], }, }, })) async for raw in ws: msg = json.loads(raw) print(msg["type"], msg.get("message")) asyncio.run(main()) ``` Messages arrive as JSON with a `type` field (the event name) and a `message` payload. See the individual room pages for the events each room emits. ## Connection lifecycle A typical session follows the same sequence for every room: ```mermaid theme={null} sequenceDiagram participant C as Client participant S as Struct C->>S: WebSocket handshake (api-key) S-->>C: 101 Switching Protocols C->>S: join_room { room_id } S-->>C: joined_room { room_id } C->>S: room_message { action: "subscribe", filters } S-->>C: _subscribe_response loop Live stream S-->>C: room event end C->>S: ping (every 30s) S-->>C: pong C->>S: room_message { action: "unsubscribe_all" } C->>S: leave_room { room_id } C->>S: Close frame ``` ## Message protocol All messages sent and received are JSON. Client messages use a `type` and `payload` envelope: ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_trades" } } ``` ### Joining and subscribing Subscribing to a room is a two-step flow: 1. **Join** the room with `join_room`. 2. **Configure** the subscription by sending a `room_message` with `action: "subscribe"` and any filters. ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "condition_ids": ["0xabc..."], "market_slugs": ["will-x-happen"] } } } ``` You can update a subscription at any time by sending another `subscribe` message. The new filters replace the previous ones. Subscribe server-side rather than filter client-side: server-side filtering is free, while client-side filtering still bills you for the message. ### Unsubscribing To stop receiving messages from a room, send `unsubscribe_all` followed by `leave_room`: ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "unsubscribe_all" } } } ``` ```json theme={null} { "type": "leave_room", "payload": { "room_id": "polymarket_trades" } } ``` ### Keepalive Send a ping every 30 seconds to keep the connection alive: ```json theme={null} { "type": "ping" } ``` The server responds with `{ "type": "pong" }`, which you can safely ignore. The TypeScript SDK handles ping/pong automatically. ## Firehose subscriptions Most rooms accept `subscribe_all: true` on the subscribe message to receive **every** update on that stream instead of a targeted subset: ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_order_book", "message": { "action": "subscribe", "subscribe_all": true } } } ``` Targeting filters (`condition_ids`, `traders`, `wallets`, and so on) are ignored while it's set, while secondary filters like `timeframes` still apply. See [Firehose](/websockets/firehose) for the per-room behaviour table and billing guidance. ## Available rooms Every room emits its own event type with a typed filter and payload. Click a room for the full schema. | Room ID | Purpose | Required filter | | --------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------- | | [`polymarket_trades`](/websockets/rooms/trades) | Trades, redemptions, merges, oracle lifecycle. | None | | [`polymarket_oracle_events`](/websockets/rooms/oracle-events) | UMA oracle lifecycle (proposals, disputes, resolutions). | None | | [`polymarket_asset_prices`](/websockets/rooms/asset-prices) | Raw Chainlink price ticks for crypto assets. | None | | [`polymarket_asset_window_updates`](/websockets/rooms/asset-window-updates) | Open/close candle ticks for crypto assets. | None | | [`polymarket_market_metrics`](/websockets/rooms/market-metrics) | Per-market volume, holders, traders, OI. | `condition_ids` | | [`polymarket_event_metrics`](/websockets/rooms/event-metrics) | Aggregated event metrics. | `event_slugs` | | [`polymarket_position_metrics`](/websockets/rooms/position-metrics) | Per-outcome volume and trader counts. | `position_ids` | | [`polymarket_tag_metrics`](/websockets/rooms/tag-metrics) | Aggregated metrics per tag. | `tags` | | [`polymarket_trader_pnl`](/websockets/rooms/trader-pnl) | Global, market, and event PnL for tracked traders. | `traders` | | [`polymarket_trader_positions`](/websockets/rooms/trader-positions) | Open and closed positions per wallet. | `traders` | | [`polymarket_accounts`](/websockets/rooms/accounts) | pUSD, USDC.e, and MATIC balance updates. | `wallets` | | [`polymarket_order_book`](/websockets/rooms/order-book) | CLOB bid/ask updates. | None | | [`polymarket_clob_rewards`](/websockets/rooms/clob-rewards) | CLOB reward configuration changes. | None | | [`polymarket_events_stream`](/websockets/rooms/events-stream) | Periodic snapshots of event lists at a configurable interval. | None | | [`polymarket_markets_stream`](/websockets/rooms/markets-stream) | Periodic snapshots of market lists at a configurable interval. | None | | [`polymarket_position_liquidity`](/websockets/rooms/liquidity) | Per-position USD order-book liquidity. | None | | [`polymarket_market_liquidity`](/websockets/rooms/liquidity) | Per-market total USD order-book liquidity. | None | | [`polymarket_event_liquidity`](/websockets/rooms/liquidity) | Per-event total USD order-book liquidity. | None | Per-message pricing is on [WebSocket Pricing](/websockets/pricing). ## Reconnection Subscriptions live only for the lifetime of the connection. If the socket drops, you must reconnect and resubscribe. Use exponential backoff with jitter so transient outages don't turn into thundering-herd reconnects. ```typescript theme={null} let attempt = 0; const subscriptions: object[] = [/* track the subscribe payloads you sent */]; function connect() { const ws = new WebSocket("wss://api.struct.to/ws?api-key=YOUR_API_KEY"); ws.onopen = () => { attempt = 0; for (const sub of subscriptions) ws.send(JSON.stringify(sub)); }; ws.onclose = (event) => { if (event.code === 1008 || event.code === 4001) return; const delay = Math.min(30_000, 1_000 * 2 ** attempt); attempt += 1; setTimeout(connect, delay + Math.random() * 500); }; } connect(); ``` The TypeScript SDK does this for you, replaying every active subscription on reconnect. See [SDK WebSockets](/sdk/websockets#reconnection-and-replay). For the full close-code reference, see [Errors](/guides/errors#close-codes). ## Connection limits Each plan has a cap on concurrent connections across your organisation. A single connection can subscribe to many rooms, so you rarely need more than a handful of sockets in practice. | Plan | Concurrent connections | | ---------- | ---------------------- | | Free | 1 | | Hobby | 50 | | Startup | 250 | | Scale | 1,000 | | Enterprise | Unlimited | See [Rate Limits](/guides/rate-limits) for filter limits and the full per-key throughput matrix. ## Next steps * Browse the [Rooms](/websockets/rooms/trades) section for every available stream and its filters. * Review [WebSocket Pricing](/websockets/pricing) for per-message rates. * Check out [Alerts](/websockets/alerts/getting-started) for webhook-style events over the same protocol. * Read [Best Practices](/guides/best-practices) for cost and resilience patterns. # Pricing Source: https://docs.struct.to/websockets/pricing How websocket usage is billed in credits. Websockets are billed **per message**. You're charged only for the messages we actually deliver to your connection. Rates vary by room so you can pick the streams that fit your cost model. ## Connection Hold When you open a new websocket connection, a **1 credit hold** is placed immediately. This is charged regardless of how long you stay connected, even if you disconnect before receiving a single message. Rapidly opening and closing connections will still incur the 1 credit hold on every connect. Keep long-lived connections where possible. ## Integer Rounding Credits are an integer-based unit, so every charge is **rounded up** to the nearest whole credit. For example, if a billing period accumulates 1.1 credits of websocket usage, you'll be charged **2 credits**. This means very small amounts of usage still cost at least 1 credit. For accurate cost modelling, aggregate messages over longer windows rather than single events. ## Per-Room Rates Each room has its own rate per message delivered: | Room | Room ID | Rate per message | | -------------------- | --------------------------------- | ---------------- | | Trades | `polymarket_trades` | 0.002 credits | | Oracle Events | `polymarket_oracle_events` | 0.002 credits | | Asset Prices | `polymarket_asset_prices` | 0.005 credits | | Asset Window Updates | `polymarket_asset_window_updates` | 0.025 credits | | Market Metrics | `polymarket_market_metrics` | 0.025 credits | | Event Metrics | `polymarket_event_metrics` | 0.025 credits | | Position Metrics | `polymarket_position_metrics` | 0.025 credits | | Tag Metrics | `polymarket_tag_metrics` | 0.025 credits | | Trader PnL | `polymarket_trader_pnl` | 0.1 credits | | Trader Positions | `polymarket_trader_positions` | 0.005 credits | | Trader Exit Markers | `polymarket_trader_pnl_exits` | 0.025 credits | | Holder Metrics | `polymarket_holder_metrics` | 0.025 credits | | Accounts | `polymarket_accounts` | 0.005 credits | | Order Book | `polymarket_order_book` | 0.001 credits | | CLOB Rewards | `polymarket_clob_rewards` | 0.01 credits | | Events Stream | `polymarket_events_stream` | 0.025 credits | | Markets Stream | `polymarket_markets_stream` | 0.025 credits | | Position Liquidity | `polymarket_position_liquidity` | 0.001 credits | | Market Liquidity | `polymarket_market_liquidity` | 0.001 credits | | Event Liquidity | `polymarket_event_liquidity` | 0.001 credits | Message rates apply to each message actually delivered to your connection, not to every event that occurs upstream. Filters you pass on subscription reduce the number of messages you receive, and therefore your bill. ## Example Calculations A connection subscribed to the Trades room, filtered to 5 condition IDs, receiving an average of 400 messages/hour: ``` (1 credit hold) + (400 × 0.002) = 1 + 0.8 → rounded up = 2 credits/hour ``` A single connection to Asset Prices filtered to BTC and ETH, averaging 120 ticks/minute (7,200/hour): ``` (1 credit hold) + (7,200 × 0.005) = 1 + 36 = 37 credits/hour ``` A connection to Trader PnL for one wallet that updates \~30 times/hour: ``` (1 credit hold) + (30 × 0.1) = 1 + 3 = 4 credits/hour ``` ## Connection Caps Each plan has a cap on how many websocket connections you can have open concurrently across your organisation: | Plan | Concurrent connections | | ---------- | ---------------------- | | Free | 1 | | Hobby | 50 | | Startup | 250 | | Scale | 1,000 | | Enterprise | Unlimited | If you need more, reach out to us at [support@struct.to](mailto:support@struct.to). ## Reducing Costs * **Use filters.** Every room supports filters that narrow down which messages are delivered to your connection. A Trades room with no filters is far more expensive than one filtered to specific condition IDs. * **Batch by connection, not by subscription.** A single connection can subscribe to multiple rooms, so you only pay the 1 credit hold once. * **Pick the cheapest stream for your use case.** If all you need is order book state, the Order Book room at 0.001 credits/msg is substantially cheaper than other streams. * **Avoid reconnect loops.** Every connect incurs the 1 credit hold. Handle temporary network issues with backoff instead of tight reconnect cycles. # Accounts Source: https://docs.struct.to/websockets/rooms/accounts Stream share balance, USDCe, pUSD, and MATIC updates for tracked wallets. **Room ID:** `polymarket_accounts` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.005 credits per message Stream share balance, USDCe (V1 collateral), pUSD (V2 CLOB collateral), and MATIC updates for tracked wallets. By default only share balance updates are delivered. Enable `include_usdce`, `include_pusd`, and `include_matic` to receive those balance types as well. ## Subscribe The `wallets` filter is required. Optionally enable USDCe, pUSD, and MATIC balance updates with the boolean flags. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `wallets` | `string[]` | Yes | Wallet addresses to track (0x-prefixed) | | `include_usdce` | `boolean` | No | Include USDCe collateral balance updates, V1 (default: false) | | `include_pusd` | `boolean` | No | Include pUSD collateral balance updates, V2 CLOB (default: false) | | `include_matic` | `boolean` | No | Include MATIC gas balance updates (default: false) | | `subscribe_all` | `boolean` | No | Firehose: receive balance updates for every wallet. The `wallets` filter is ignored when set; the `include_*` flags still gate the extra token streams | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_accounts" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_accounts", "message": { "action": "subscribe", "wallets": ["0x1234567890abcdef1234567890abcdef12345678"], "include_usdce": true, "include_pusd": true, "include_matic": true } } } ``` ### Response ```json theme={null} { "type": "accounts_stream_subscribe_response", "room_id": "polymarket_accounts", "data": { "wallets": ["0x1234567890abcdef1234567890abcdef12345678"], "rejected": [], "include_usdce": true, "include_pusd": true, "include_matic": true } } ``` ## Events ### `accounts_update` Share balance update for a tracked wallet. ```json theme={null} { "type": "accounts_update", "room_id": "polymarket_accounts", "data": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "position_id": "12345678901234567", "balance": "500.000000", "block_number": 65000000, "updated_at": 1743500000, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "event_slug": "bitcoin-price-markets" } } ``` ### `usdce_update` USDCe (V1) collateral balance update for a tracked wallet. ```json theme={null} { "type": "usdce_update", "room_id": "polymarket_accounts", "data": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "balance": "10250.500000", "block_number": 65000000, "updated_at": 1743500000 } } ``` ### `pusd_update` pUSD (V2 CLOB) collateral balance update for a tracked wallet. ```json theme={null} { "type": "pusd_update", "room_id": "polymarket_accounts", "data": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "token_address": "0xc011a7E12A19f7B1F670D46F03b03f3342e82DfB", "balance": "10250.500000", "block_number": 65000000, "updated_at": 1743500000 } } ``` ### `matic_update` MATIC balance update for a tracked wallet. ```json theme={null} { "type": "matic_update", "room_id": "polymarket_accounts", "data": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "token_address": "0x0000000000000000000000000000000000001010", "balance": "25.750000000000000000", "block_number": 65000000, "updated_at": 1743500000 } } ``` # Asset Prices Source: https://docs.struct.to/websockets/rooms/asset-prices Stream real-time crypto asset price ticks. **Room ID:** `polymarket_asset_prices` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.005 credits per message Stream real-time crypto asset price ticks. Leave `asset_symbols` empty to subscribe to all available assets. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) uses this room for live spot ticks behind Up/Down markets. ## Subscribe All filters are optional. If `asset_symbols` is omitted or empty, the room subscribes to price ticks for every tracked asset. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | --------------------------------------------- | | `asset_symbols` | `string[]` | No | Asset symbols to track, e.g. `["BTC", "ETH"]` | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_asset_prices" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_asset_prices", "message": { "action": "subscribe", "asset_symbols": ["BTC", "ETH"] } } } ``` ### Response ```json theme={null} { "type": "asset_prices_stream_subscribe_response", "room_id": "polymarket_asset_prices", "data": { "asset_symbols": ["BTC", "ETH"] } } ``` ## Events ### `asset_price_tick` ```json theme={null} { "type": "asset_price_tick", "room_id": "polymarket_asset_prices", "data": { "event_type": "tick", "symbol": "BTC", "price": 97250.50, "timestamp_ms": 1743500000000, "published_at": 1743500000000 } } ``` ### `asset_price_window_update` ```json theme={null} { "type": "asset_price_window_update", "room_id": "polymarket_asset_prices", "data": { "event_type": "window_update", "symbol": "ETH", "variant": "1h", "start_time": 1743496400000, "end_time": 1743500000000, "open_price": 3200.0, "close_price": 3215.50, "update_type": "close", "published_at": 1743500000000 } } ``` # Asset Window Updates Source: https://docs.struct.to/websockets/rooms/asset-window-updates Stream crypto asset candle open and close events. **Room ID:** `polymarket_asset_window_updates` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream candle open and close events for crypto assets. At least one of `asset_symbols` or `timeframes` is required. **Related guide:** [Crypto Up/Down feed](/guides/crypto-up-down-feed) uses this room for live window open and close resolutions. ## Subscribe You must provide at least one filter. Specify the assets you want to track and the candle timeframes you are interested in. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | ---------------------------- | ----------------------------------------------------------------------------------------------------- | | `asset_symbols` | `string[]` | At least one filter required | Asset symbols to track, e.g. `["BTC", "ETH"]` | | `timeframes` | `string[]` | At least one filter required | One or more of `5m`, `15m`, `1h`, `4h`, `1d`, `24h` | | `subscribe_all` | `boolean` | No | Firehose: receive every window update across all symbols and timeframes. Filters are ignored when set | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_asset_window_updates" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_asset_window_updates", "message": { "action": "subscribe", "asset_symbols": ["BTC"], "timeframes": ["1h"] } } } ``` ### Response ```json theme={null} { "type": "asset_window_updates_stream_subscribe_response", "room_id": "polymarket_asset_window_updates", "data": { "asset_symbols": ["BTC"], "timeframes": ["1h"] } } ``` ## Events ### `asset_price_window_update` ```json theme={null} { "type": "asset_price_window_update", "room_id": "polymarket_asset_window_updates", "data": { "event_type": "window_update", "symbol": "BTC", "variant": "5m", "start_time": 1743499700000, "end_time": 1743500000000, "open_price": 97200.0, "close_price": 97250.50, "update_type": "open", "published_at": 1743500000000 } } ``` # CLOB Rewards Source: https://docs.struct.to/websockets/rooms/clob-rewards Stream CLOB reward changes for markets. **Room ID:** `polymarket_clob_rewards` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.01 credits per message Stream CLOB reward changes for Polymarket markets. You can subscribe to specific markets by condition ID. If `condition_ids` is omitted or empty, the room subscribes to all markets by default. A maximum of 500 filters is allowed per client. **Related guide:** [Ranking markets by CLOB rewards](/guides/clob-liquidity-markets-dashboard) combines this room with the markets list to surface markets paying the highest daily rates. ## Subscribe All filters are optional. If `condition_ids` is omitted or empty, the room subscribes to all markets by default. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ----------------------------------------------------------------------------------- | | `condition_ids` | `string[]` | No | Specific markets to track by condition ID | | `subscribe_all` | `boolean` | No | Firehose: receive all reward changes across every market. Overrides `condition_ids` | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_clob_rewards" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_clob_rewards", "message": { "action": "subscribe" } } } ``` ### Response ```json theme={null} { "type": "clob_rewards_stream_subscribe_response", "room_id": "polymarket_clob_rewards", "data": { "condition_ids": [], "rejected": [] } } ``` ## Events ### `clob_rewards_update` The event includes an `event_type` field indicating whether a reward was `added`, `removed`, or `updated`. ```json theme={null} { "type": "clob_rewards_update", "room_id": "polymarket_clob_rewards", "data": { "event_type": "updated", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "reward": { "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "rewards_config": [ { "id": 1, "asset_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "start_date": "2025-01-01", "end_date": "2025-12-31", "rate_per_day": 50.0, "total_rewards": 18250.0 } ], "rewards_max_spread": 0.04, "rewards_min_size": 20.0, "native_daily_rate": 100.0, "sponsored_daily_rate": 50.0, "total_daily_rate": 150.0, "sponsors_count": 1 }, "timestamp_ms": 1743500000000 } } ``` For `removed` events, the `reward` field is `null`. # Event Metrics Source: https://docs.struct.to/websockets/rooms/event-metrics Stream aggregated event-level metric updates. **Room ID:** `polymarket_event_metrics` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream aggregated metric updates at the event level. This room provides volume, fee, and activity data rolled up across all markets within an event. ## Subscribe The `event_slugs` filter is required. You may optionally specify `timeframes` to limit which windows you receive. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `event_slugs` | `string[]` | Yes | Events to track by slug | | `timeframes` | `string[]` | No | Timeframe windows to receive updates for | | `subscribe_all` | `boolean` | No | Firehose: receive every event's metrics. `event_slugs` are ignored when set; the `timeframes` filter still applies | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_event_metrics" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_event_metrics", "message": { "action": "subscribe", "event_slugs": ["us-presidential-election-2026"] } } } ``` ### Response ```json theme={null} { "type": "event_metrics_stream_subscribe_response", "room_id": "polymarket_event_metrics", "data": { "event_slugs": ["us-presidential-election-2026"], "timeframes": [], "rejected": [] } } ``` ## Events ### `event_metrics_update` **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. USD and shares volumes are decimal strings to preserve precision. Builder-attributed fields cover the subset of activity routed through builder-signed orders. ```json theme={null} { "type": "event_metrics_update", "room_id": "polymarket_event_metrics", "data": { "event_slug": "us-presidential-election-2026", "timeframe": "1h", "timestamp": 1743500000, "usd_volume": "2500000.00", "shares_volume": "3850000.00", "builder_usd_volume": "620000.00", "builder_shares_volume": "955000.00", "fees": 5000.0, "builder_fees": 1240.0, "txns": 12400, "builder_txns": 3100, "unique_traders": 3200, "unique_builder_traders": 820 } } ``` # Events Stream Source: https://docs.struct.to/websockets/rooms/events-stream Stream real-time Polymarket event rows with filter or ids mode and configurable cadence. **Room ID:** `polymarket_events_stream` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Low-latency push feed of `PolymarketEvent` rows — same shape that `GET /polymarket/events` returns. The server maintains an in-memory cache of **open** events only, refreshed via a slow full poll plus a fast 500ms newest-first poll, and merged live from the `prediction_event_metrics` and `prediction_trades` Kafka streams with per-timeframe block/timestamp ordering. No initial snapshot is pushed on subscribe. Clients seed local state from `GET /polymarket/events` and then apply deltas from this stream. **Related guide:** [Building a live trending feed](/guides/real-time-trending-events-and-markets) seeds from the events list and pushes deltas through this stream. ## Subscription model Each client has up to **8 active slots per room** (4 cadences × 2 modes). Re-subscribing to the same `(interval_ms, mode)` pair replaces the previous subscription. Unsubscribe one slot with `action: "unsubscribe"` plus `interval_ms` and `mode`, or clear everything with `action: "unsubscribe_all"`. * **Cadence (`interval_ms`):** `500`, `1000`, `3000`, or `10000`. Each cadence is a separate flush bucket. * **Filter mode:** evaluated in-memory on every flush against rows that actually changed since the last tick. Same validation as the REST list endpoint. * **Ids mode:** filter by explicit `event_slugs` and/or `event_ids`. Max **500 ids** per subscription. Updates fire only when a cache row is dirtied by (a) a fresh Kafka metric snapshot with `latest_block >= cached`, (b) a confirmed trade with `block >= cached`, (c) a slow-poll field diff, or (d) the fast newest-first poll discovering a brand-new event. Quiet events produce zero messages. ## Subscribe ### Message fields | Field | Type | Required | Description | | ------------- | --------------------------------------------------- | --------------------------- | --------------------------------------------- | | `action` | `"subscribe" \| "unsubscribe" \| "unsubscribe_all"` | Yes | Slot lifecycle action. | | `interval_ms` | `500 \| 1000 \| 3000 \| 10000` | For subscribe / unsubscribe | Flush cadence. Defaults to `1000` if omitted. | | `mode` | `"filter" \| "ids"` | No | Subscription mode. Defaults to `filter`. | | `filter` | `EventsStreamFilter` | `mode=filter` only | Filter body; all fields optional. | | `event_slugs` | `string[]` | `mode=ids` only | Event slugs to watch. | | `event_ids` | `string[]` | `mode=ids` only | Event ids to watch. | ### Filter fields (`mode=filter`) `search` is a case-insensitive substring match on `title` (3–100 chars). All other fields follow the same validation as the REST list endpoint (timeframe, list size caps). ### Example — filter mode ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_events_stream" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_events_stream", "message": { "action": "subscribe", "interval_ms": 1000, "mode": "filter", "filter": { "search": "election", "timeframe": "24h", "min_volume": 10000 } } } } ``` ### Example — ids mode ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_events_stream", "message": { "action": "subscribe", "interval_ms": 500, "mode": "ids", "event_slugs": ["us-election-2028"], "event_ids": ["12345"] } } } ``` ### Unsubscribe one slot ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_events_stream", "message": { "action": "unsubscribe", "interval_ms": 500, "mode": "ids" } } } ``` ### Response ```json theme={null} { "type": "events_stream_subscribe_response", "room_id": "polymarket_events_stream", "data": { "mode": "ids", "interval_ms": 500, "event_slugs": ["us-election-2028"], "rejected": [], "error": null } } ``` ## Events ### `events_stream_update` Server-pushed event fired only for rows that changed AND matched this subscription since the last flush tick. `data` contains **full** `PolymarketEvent` rows — not deltas — so clients should merge by `id`. #### Envelope | Field | Type | Description | | ------------- | ------------------------------ | -------------------------------------------------------- | | `type` | `"events_stream_update"` | Envelope discriminator. | | `room_id` | `"polymarket_events_stream"` | Room identifier. | | `mode` | `"filter" \| "ids"` | The mode this subscription was created with. | | `interval_ms` | `500 \| 1000 \| 3000 \| 10000` | The cadence slot this event is flushed under. | | `data` | `PolymarketEvent[]` | Full event rows, same shape as `GET /polymarket/events`. | #### `PolymarketEvent` | Field | Type | Description | | -------------------- | ------------------------------------------- | ----------------------------------------------------------------------- | | `id` | `string` | Event ID. | | `event_slug` | `string \| null` | URL-safe event slug. | | `title` | `string \| null` | Human-readable title. | | `ticker` | `string \| null` | Short ticker, when present. | | `description` | `string \| null` | Long description. | | `resolution_source` | `string \| null` | Resolution source URL. | | `category` | `string \| null` | Primary category label. | | `image_url` | `string \| null` | CDN image URL. | | `market_count` | `integer` | Number of child markets. | | `created_time` | `integer \| null` | Unix seconds. | | `closed_time` | `integer \| null` | Unix seconds. | | `start_time` | `integer \| null` | Unix seconds. | | `end_time` | `integer \| null` | Unix seconds. | | `neg_risk` | `boolean` | Whether this event uses the neg-risk market. | | `neg_risk_market_id` | `string \| null` | Neg-risk market ID, when applicable. | | `game_status` | `string \| null` | Sports-event game status. | | `show_market_images` | `boolean` | Whether child market images should be shown. | | `status` | `string \| null` | `"open"` or `"closed"`. | | `metrics` | `Record` | Keyed by timeframe (`1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`). | | `tags` | `PolymarketTag[]` | Event tags. | | `markets` | `EventMarket[]` | Child markets with enriched outcomes. | | `series` | `PolymarketSeries \| null` | Parent series, when present. | #### `SimpleTimeframeMetrics` | Field | Type | Description | | ---------------- | --------- | -------------------------------------- | | `volume` | `number` | USD volume within the window. | | `fees` | `number` | USD fees within the window. | | `txns` | `integer` | Trade count within the window. | | `unique_traders` | `integer` | Unique wallet count within the window. | #### `PolymarketTag` | Field | Type | Description | | ------- | ---------------- | -------------- | | `id` | `string` | Tag ID. | | `label` | `string` | Display label. | | `slug` | `string \| null` | URL-safe slug. | #### `EventMarket` | Field | Type | Description | | ----------------------- | ---------------------------- | ------------------------------------------ | | `condition_id` | `string` | 0x-prefixed condition ID. | | `id` | `string \| null` | Market ID. | | `title` | `string \| null` | Market title. | | `question` | `string` | Market question. | | `market_slug` | `string` | URL-safe market slug. | | `status` | `string` | Market status. | | `created_time` | `integer \| null` | Unix seconds. | | `end_time` | `integer \| null` | Unix seconds. | | `volume` | `number \| null` | Lifetime USD volume. | | `liquidity_usd` | `number \| null` | Current USD liquidity. | | `volume_24hr` | `number \| null` | 24h USD volume. | | `image_url` | `string \| null` | CDN image URL. | | `market_maker_address` | `string \| null` | Market maker contract. | | `creator` | `string \| null` | Wallet address of creator. | | `category` | `string \| null` | Category label. | | `accepting_orders` | `boolean \| null` | Whether CLOB is accepting new orders. | | `uma_resolution_status` | `string \| null` | UMA oracle resolution status. | | `clob_rewards` | `ClobReward[]` | Active reward configs. | | `outcomes` | `EventMarketOutcome[]` | Market outcomes. | | `winning_outcome` | `EventMarketOutcome \| null` | Resolved winning outcome, when applicable. | #### `EventMarketOutcome` | Field | Type | Description | | --------------- | ----------------- | ------------------------------------------- | | `name` | `string` | Outcome label (e.g. `"Yes"`). | | `price` | `number \| null` | Latest price (0 – 1). | | `position_id` | `string \| null` | ERC-1155 outcome token ID (decimal string). | | `outcome_index` | `integer \| null` | 0-indexed outcome position. | #### `PolymarketSeries` | Field | Type | Description | | --------------- | ----------------- | ---------------------------------------- | | `id` | `string` | Series ID. | | `slug` | `string \| null` | URL-safe slug. | | `ticker` | `string \| null` | Ticker, when present. | | `title` | `string \| null` | Display title. | | `description` | `string \| null` | Long description. | | `series_type` | `string \| null` | Series type discriminator. | | `recurrence` | `string \| null` | Recurrence cadence (e.g. `"daily"`). | | `layout` | `string \| null` | UI layout hint. | | `image_url` | `string \| null` | CDN image URL. | | `icon_url` | `string \| null` | CDN icon URL. | | `active` | `boolean` | Whether the series is currently active. | | `closed` | `boolean` | Whether the series is closed. | | `archived` | `boolean` | Whether archived. | | `featured` | `boolean` | Whether featured in discovery surfaces. | | `restricted` | `boolean` | Whether the series is region-restricted. | | `pyth_token_id` | `string \| null` | Pyth price feed ID, when applicable. | | `cg_asset_name` | `string \| null` | CoinGecko asset name, when applicable. | | `start_date` | `integer \| null` | Unix seconds. | | `event_count` | `integer` | Number of child events. | See the [Markets Stream](/websockets/rooms/markets-stream) room for the `ClobReward` shape — it is shared across both stream rooms. #### Example ```json theme={null} { "type": "events_stream_update", "room_id": "polymarket_events_stream", "mode": "ids", "interval_ms": 500, "data": [ { "id": "12345", "event_slug": "us-election-2028", "title": "US Presidential Election 2028", "ticker": "PRES28", "description": "Who will win the 2028 US presidential election?", "resolution_source": "https://example.com/resolution", "category": "politics", "image_url": "https://cdn.struct.to/events/us-election-2028.png", "market_count": 8, "created_time": 1743400000, "closed_time": null, "start_time": 1743500000, "end_time": 1893456000, "neg_risk": true, "neg_risk_market_id": "0xneg...", "game_status": null, "show_market_images": true, "status": "open", "metrics": { "24h": { "volume": 125000.5, "fees": 250.0, "txns": 340, "unique_traders": 85 }, "7d": { "volume": 810000.0, "fees": 1620.0, "txns": 2210, "unique_traders": 540 } }, "tags": [{ "id": "42", "label": "Politics", "slug": "politics" }], "markets": [ { "condition_id": "0xabc123...", "id": "m_1", "title": "Candidate A wins", "question": "Will Candidate A win the 2028 election?", "market_slug": "candidate-a-wins-2028", "status": "open", "created_time": 1743400000, "end_time": 1893456000, "volume": 42000.0, "liquidity_usd": 15000.0, "volume_24hr": 3200.0, "image_url": "https://cdn.struct.to/markets/candidate-a.png", "market_maker_address": "0xmm...", "creator": "0xcreator...", "category": "politics", "accepting_orders": true, "uma_resolution_status": null, "clob_rewards": [], "outcomes": [ { "name": "Yes", "price": 0.42, "position_id": "12345678901234567", "outcome_index": 0 }, { "name": "No", "price": 0.58, "position_id": "98765432109876543", "outcome_index": 1 } ], "winning_outcome": null } ], "series": null } ] } ``` # Holder Metrics Source: https://docs.struct.to/websockets/rooms/holder-metrics Stream real-time holder counts for positions, conditions, and events. **Room ID:** `polymarket_holder_metrics` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream real-time holder counts for positions, conditions (markets), and events, batched per block. Subscribe with at least one identifier array. Only the families you filter on are delivered. **Related guide:** [Holder metrics tracking](/guides/holder-metrics-tracking) tracks holder growth across positions, markets, and events. ## Subscribe At least one identifier array is required. Each array accepts up to 500 entries, and the combined total across all arrays may not exceed 500. You receive only the families whose filter you supply. ### Filters | Filter | Type | Description | | --------------- | ---------- | --------------------------------------------------------------------------- | | `position_ids` | `string[]` | Position token IDs, delivering position holder metrics | | `condition_ids` | `string[]` | Condition IDs, delivering condition holder metrics | | `event_slugs` | `string[]` | Event slugs, delivering event holder metrics | | `subscribe_all` | `boolean` | Firehose: receive every holder metrics update. Filters are ignored when set | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_holder_metrics" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_holder_metrics", "message": { "action": "subscribe", "condition_ids": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] } } } ``` ### Response ```json theme={null} { "type": "holder_metrics_stream_subscribe_response", "room_id": "polymarket_holder_metrics", "data": { "position_ids": [], "condition_ids": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"], "event_slugs": [], "rejected": [], "error": null } } ``` ## Events ### `holder_metrics_position_batch` Holder metrics per position. ```json theme={null} { "type": "holder_metrics_position_batch", "room_id": "polymarket_holder_metrics", "block": 68420001, "data": [ { "ts": 1743500000, "block": 68420001, "position_id": "12345678901234567", "holder_count": 1820, "total_balance": 4250000.0, "total_cost_basis": 2870000.0, "condition_holder_count": 3100, "event_holder_count": 5400 } ] } ``` | Field | Type | Description | | ------------------------ | ------------- | --------------------------------------- | | `ts` | `int` | Timestamp (Unix seconds) | | `block` | `int` | Block number | | `position_id` | `string` | Position token ID | | `holder_count` | `int` | Holders of this position | | `total_balance` | `number` | Total shares held across holders | | `total_cost_basis` | `number` | Total cost basis across holders, in USD | | `condition_holder_count` | `int \| null` | Holders of the owning condition | | `event_holder_count` | `int \| null` | Holders of the owning event | ### `holder_metrics_condition_batch` Holder metrics per condition (market). ```json theme={null} { "type": "holder_metrics_condition_batch", "room_id": "polymarket_holder_metrics", "block": 68420001, "data": [ { "ts": 1743500000, "block": 68420001, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "holder_count": 3100 } ] } ``` | Field | Type | Description | | -------------- | -------- | ------------------------- | | `ts` | `int` | Timestamp (Unix seconds) | | `block` | `int` | Block number | | `condition_id` | `string` | Condition ID | | `holder_count` | `int` | Holders of this condition | ### `holder_metrics_event_batch` Holder metrics per event. ```json theme={null} { "type": "holder_metrics_event_batch", "room_id": "polymarket_holder_metrics", "block": 68420001, "data": [ { "ts": 1743500000, "block": 68420001, "event_slug": "bitcoin-price-markets", "holder_count": 5400 } ] } ``` | Field | Type | Description | | -------------- | -------- | ------------------------ | | `ts` | `int` | Timestamp (Unix seconds) | | `block` | `int` | Block number | | `event_slug` | `string` | Event slug | | `holder_count` | `int` | Holders of this event | # Liquidity Source: https://docs.struct.to/websockets/rooms/liquidity Stream real-time USD order-book liquidity for positions, markets, and events. **Room IDs:** `polymarket_position_liquidity`, `polymarket_market_liquidity`, `polymarket_event_liquidity` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.001 credits per message Stream real-time USD order-book liquidity at three granularities. Each room pushes an update whenever the relevant liquidity changes. | Room ID | Granularity | Filter (optional) | | ------------------------------- | -------------------------------------------------------- | ----------------- | | `polymarket_position_liquidity` | Per outcome token (position) | `position_ids` | | `polymarket_market_liquidity` | Per market (condition), summed across its outcome tokens | `condition_ids` | | `polymarket_event_liquidity` | Per event, summed across its markets | `event_slugs` | All three rooms share the same subscribe flow, acknowledgement shape, and event fields. Liquidity is reported in USD as `liquidity_usd`, and `liquidity_updated_at` is a Unix-millisecond timestamp. ## Subscribe Filters are optional. Omit the identifier array (or send an empty one) to receive every update for that granularity (a firehose). When provided, each room accepts up to 500 identifiers. Update a subscription at any time by sending another `subscribe` message, and stop it with `action: "unsubscribe_all"`. ### Filters | Room | Filter | Type | Description | | ------------------------------- | --------------- | ---------- | ---------------------------------------------------------------------------------- | | `polymarket_position_liquidity` | `position_ids` | `string[]` | ERC-1155 outcome token IDs (decimal or hex). Empty or omitted means all positions. | | `polymarket_market_liquidity` | `condition_ids` | `string[]` | 64-char hex market IDs. Empty or omitted means all markets. | | `polymarket_event_liquidity` | `event_slugs` | `string[]` | Event slugs. Empty or omitted means all events. | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_market_liquidity" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_market_liquidity", "message": { "action": "subscribe", "condition_ids": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] } } } ``` ### Response All three rooms acknowledge with the same `liquidity_stream_subscribe_response` envelope. `data.ids` echoes the accepted filter values (empty for a firehose subscription), and `data.rejected` lists any identifiers that failed validation. ```json theme={null} { "type": "liquidity_stream_subscribe_response", "room_id": "polymarket_market_liquidity", "data": { "ids": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"], "rejected": [] } } ``` ## Events Each room emits a single update object (not a batch) whenever liquidity changes. Every event carries `liquidity_usd` (current USD order-book liquidity) and `liquidity_updated_at` (Unix milliseconds). ### `position_liquidity_update` Latest USD liquidity for one outcome token, from `polymarket_position_liquidity`. ```json theme={null} { "type": "position_liquidity_update", "room_id": "polymarket_position_liquidity", "data": { "position_id": "12345678901234567", "liquidity_usd": 42250.75, "liquidity_updated_at": 1743500000000 } } ``` | Field | Type | Description | | ---------------------- | -------- | ------------------------------------------ | | `position_id` | `string` | ERC-1155 outcome token ID (decimal string) | | `liquidity_usd` | `number` | Order-book liquidity in USD | | `liquidity_updated_at` | `int` | Update timestamp (Unix milliseconds) | ### `market_liquidity_update` Total USD liquidity for one market, summed across its outcome tokens, from `polymarket_market_liquidity`. ```json theme={null} { "type": "market_liquidity_update", "room_id": "polymarket_market_liquidity", "data": { "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "liquidity_usd": 128400.0, "liquidity_updated_at": 1743500000000 } } ``` | Field | Type | Description | | ---------------------- | -------- | ------------------------------------ | | `condition_id` | `string` | 64-char hex condition ID | | `liquidity_usd` | `number` | Total order-book liquidity in USD | | `liquidity_updated_at` | `int` | Update timestamp (Unix milliseconds) | ### `event_liquidity_update` Total USD liquidity for one event, summed across its markets, from `polymarket_event_liquidity`. ```json theme={null} { "type": "event_liquidity_update", "room_id": "polymarket_event_liquidity", "data": { "event_slug": "bitcoin-price-markets", "liquidity_usd": 512900.0, "liquidity_updated_at": 1743500000000 } } ``` | Field | Type | Description | | ---------------------- | -------- | ------------------------------------ | | `event_slug` | `string` | Event slug | | `liquidity_usd` | `number` | Total order-book liquidity in USD | | `liquidity_updated_at` | `int` | Update timestamp (Unix milliseconds) | # Market Metrics Source: https://docs.struct.to/websockets/rooms/market-metrics Stream real-time volume, fees, and transaction metrics per market. **Room ID:** `polymarket_market_metrics` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream real-time volume, fees, and transaction metrics for specific markets. Updates are pushed whenever metric values change within the tracked timeframe windows. ## Subscribe The `condition_ids` filter is required. You may optionally specify `timeframes` to limit which windows you receive. A maximum of 100 filters is allowed per client. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `condition_ids` | `string[]` | Yes | Markets to track by condition ID | | `timeframes` | `string[]` | No | Timeframe windows to receive updates for | | `subscribe_all` | `boolean` | No | Firehose: receive metrics for every market condition. `condition_ids` are ignored when set; the `timeframes` filter still applies | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_market_metrics" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_market_metrics", "message": { "action": "subscribe", "condition_ids": ["0xabc123..."] } } } ``` ### Response ```json theme={null} { "type": "market_metrics_stream_subscribe_response", "room_id": "polymarket_market_metrics", "data": { "condition_ids": ["0xabc123..."], "timeframes": [], "rejected": [] } } ``` ## Events ### `market_metrics_update` **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. USD and shares volumes are decimal strings to preserve precision. Builder-attributed fields cover the subset of activity routed through builder-signed orders. ```json theme={null} { "type": "market_metrics_update", "room_id": "polymarket_market_metrics", "data": { "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "timeframe": "1h", "timestamp": 1743500000, "usd_volume": "125000.50", "shares_volume": "192300.00", "builder_usd_volume": "31200.00", "builder_shares_volume": "48000.00", "fees": 250.0, "builder_fees": 62.5, "txns": 340, "builder_txns": 85, "unique_traders": 85, "unique_builder_traders": 22 } } ``` # Markets Stream Source: https://docs.struct.to/websockets/rooms/markets-stream Stream real-time Polymarket market rows with filter or ids mode and configurable cadence. **Room ID:** `polymarket_markets_stream` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Low-latency push feed of market rows — same shape `GET /polymarket/market` returns, including per-outcome price with `latest_block` + `latest_confirmed_at` watermarks. The server maintains an in-memory cache of **open** markets only, refreshed via a slow full poll plus a fast 500ms newest-first poll, and merged live from the `prediction_condition_metrics` and `prediction_trades` Kafka streams with per-timeframe block/timestamp ordering. No initial snapshot is pushed on subscribe. Clients seed from `GET /polymarket/market` and then apply deltas from this stream. **Related guides:** [Building a live trending feed](/guides/real-time-trending-events-and-markets) seeds from the markets list and stays warm via this stream, and [Ranking markets by CLOB rewards](/guides/clob-liquidity-markets-dashboard) tracks live reward changes through it. ## Subscription model Each client has up to **8 active slots per room** (4 cadences × 2 modes). Re-subscribing to the same `(interval_ms, mode)` pair replaces the previous subscription. Unsubscribe one slot with `action: "unsubscribe"` plus `interval_ms` and `mode`, or clear everything with `action: "unsubscribe_all"`. * **Cadence (`interval_ms`):** `500`, `1000`, `3000`, or `10000`. * **Filter mode:** same validation as the REST list endpoint (timeframe, search length, list caps). `search` is a case-insensitive substring match on `title`. No sort / limit — you get every matching row that changed. * **Ids mode:** any combination of `condition_ids`, `market_slugs`, and `event_slugs` (matches all child markets of those events). Max **500 ids total** per subscription. Updates fire only when a cache row is dirtied by (a) a fresh Kafka metric snapshot with `latest_block >= cached`, (b) a confirmed trade with `block >= cached`, (c) a slow-poll field diff, or (d) the fast newest-first poll discovering a brand-new market. Quiet markets produce zero messages. Each outcome in `outcomes[]` carries `latest_block` and `latest_confirmed_at` (Unix seconds) — the block/ts of the most recent price write from `prediction_position_metrics`. Consumers can use these to reject out-of-order price merges locally. ## Subscribe ### Message fields | Field | Type | Required | Description | | --------------- | --------------------------------------------------- | --------------------------- | ------------------------------------------------------- | | `action` | `"subscribe" \| "unsubscribe" \| "unsubscribe_all"` | Yes | Slot lifecycle action. | | `interval_ms` | `500 \| 1000 \| 3000 \| 10000` | For subscribe / unsubscribe | Flush cadence. Defaults to `1000` if omitted. | | `mode` | `"filter" \| "ids"` | No | Subscription mode. Defaults to `filter`. | | `filter` | `MarketsStreamFilter` | `mode=filter` only | Filter body; all fields optional. | | `condition_ids` | `string[]` | `mode=ids` only | 0x-prefixed lowercase 32-byte hex. | | `market_slugs` | `string[]` | `mode=ids` only | Market slug strings. | | `event_slugs` | `string[]` | `mode=ids` only | Event slugs — matches every child market of each event. | ### Filter fields (`mode=filter`) Supports the same filters as the REST markets list: `search`, `categories`, `exclude_categories`, `tags`, `exclude_tags`, `min_volume` / `max_volume`, `min_txns` / `max_txns`, `min_unique_traders` / `max_unique_traders`, `min_liquidity` / `max_liquidity`, `min_holders` / `max_holders`, `start_time` / `end_time`, `has_rewards`, and `timeframe` (`1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`). `status` is **not** accepted — the cache only holds open markets. ### Example — filter mode ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_markets_stream" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_markets_stream", "message": { "action": "subscribe", "interval_ms": 1000, "mode": "filter", "filter": { "search": "bitcoin", "categories": ["crypto"], "min_volume": 50000, "timeframe": "24h", "has_rewards": true } } } } ``` ### Example — ids mode ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_markets_stream", "message": { "action": "subscribe", "interval_ms": 500, "mode": "ids", "condition_ids": ["0xabc123..."], "market_slugs": ["will-bitcoin-hit-100k"], "event_slugs": ["bitcoin-price-markets"] } } } ``` ### Unsubscribe one slot ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_markets_stream", "message": { "action": "unsubscribe", "interval_ms": 500, "mode": "ids" } } } ``` ### Response ```json theme={null} { "type": "markets_stream_subscribe_response", "room_id": "polymarket_markets_stream", "data": { "mode": "ids", "interval_ms": 500, "condition_ids": ["0xabc123..."], "market_slugs": ["will-bitcoin-hit-100k"], "event_slugs": ["bitcoin-price-markets"], "rejected": [], "error": null } } ``` ## Events ### `markets_stream_update` Server-pushed event fired only for rows that changed AND matched this subscription since the last flush tick. `data` contains **full** market rows — not deltas — so clients should merge by `condition_id`. Each outcome in `data[i].outcomes` carries `latest_block` + `latest_confirmed_at` price-update watermarks. #### Envelope | Field | Type | Description | | ------------- | ------------------------------ | --------------------------------------------------------- | | `type` | `"markets_stream_update"` | Envelope discriminator. | | `room_id` | `"polymarket_markets_stream"` | Room identifier. | | `mode` | `"filter" \| "ids"` | The mode this subscription was created with. | | `interval_ms` | `500 \| 1000 \| 3000 \| 10000` | The cadence slot this event is flushed under. | | `data` | `MarketResponse[]` | Full market rows, same shape as `GET /polymarket/market`. | #### `MarketResponse` | Field | Type | Description | | ----------------------- | ------------------------------------------- | ----------------------------------------------------------------------- | | `condition_id` | `string` | 0x-prefixed condition ID. **Required.** | | `id` | `string \| null` | Market ID. | | `market_slug` | `string \| null` | URL-safe market slug. | | `question` | `string \| null` | Market question. | | `title` | `string \| null` | Market title. | | `description` | `string \| null` | Long description. | | `image_url` | `string \| null` | CDN image URL. | | `oracle` | `string \| null` | Oracle contract address. | | `status` | `string` | Market status. **Required** (cache holds only open markets). | | `created_time` | `integer \| null` | Unix seconds. | | `start_time` | `integer \| null` | Unix seconds. | | `game_start_time` | `integer \| null` | Unix seconds (sports markets). | | `closed_time` | `integer \| null` | Unix seconds. | | `end_time` | `integer \| null` | Unix seconds. | | `accepting_orders` | `boolean \| null` | Whether CLOB is accepting new orders. | | `uma_resolution_status` | `string \| null` | UMA oracle resolution status. | | `is_neg_risk` | `boolean \| null` | Whether this market uses the neg-risk exchange. | | `market_maker_address` | `string \| null` | Market maker contract. | | `creator` | `string \| null` | Wallet address of creator. | | `category` | `string \| null` | Category label. | | `volume_usd` | `number \| null` | Lifetime USD volume. | | `liquidity_usd` | `number \| null` | Current USD liquidity. | | `highest_probability` | `number \| null` | Highest outcome probability (0 – 1). | | `total_holders` | `integer \| null` | Unique holder count. | | `total_daily_rate` | `number \| null` | Combined daily reward rate across sponsors. | | `winning_outcome` | `MarketOutcome \| null` | Resolved winning outcome, when applicable. | | `outcomes` | `MarketOutcome[]` | Market outcomes. | | `clob_rewards` | `ClobReward[]` | Active reward configs. | | `tags` | `string[]` | Tag labels attached to the market. | | `event_slug` | `string \| null` | Parent event slug. | | `resolution_source` | `string \| null` | Resolution source URL. | | `metrics` | `Record` | Keyed by timeframe (`1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`). | | `relevance_score` | `number \| null` | Search relevance score, when applicable. | #### `MarketOutcome` | Field | Type | Description | | --------------------- | ----------------- | ------------------------------------------------------- | | `name` | `string` | Outcome label (e.g. `"Yes"`). | | `price` | `number \| null` | Latest traded price of this outcome token (0 – 1). | | `position_id` | `string \| null` | ERC-1155 outcome token ID (decimal string). | | `outcome_index` | `integer \| null` | 0-indexed outcome position. | | `latest_block` | `integer \| null` | Block of the most recent price update for this outcome. | | `latest_confirmed_at` | `integer \| null` | Unix seconds of the most recent price update. | #### `SimpleTimeframeMetrics` | Field | Type | Description | | ---------------- | --------- | -------------------------------------- | | `volume` | `number` | USD volume within the window. | | `fees` | `number` | USD fees within the window. | | `txns` | `integer` | Trade count within the window. | | `unique_traders` | `integer` | Unique wallet count within the window. | #### `ClobReward` | Field | Type | Description | | ---------------------- | ----------------- | ---------------------------------------------- | | `id` | `string` | Reward config ID. **Required.** | | `condition_id` | `string` | Market condition ID. **Required.** | | `asset_address` | `string \| null` | Reward token contract. | | `rewards_amount` | `number \| null` | Total rewards remaining. | | `rewards_daily_rate` | `number \| null` | Rewards emitted per day. | | `start_date` | `string \| null` | ISO date the reward starts emitting. | | `end_date` | `string \| null` | ISO date the reward stops emitting. | | `rewards_max_spread` | `number \| null` | Max spread eligible for rewards (probability). | | `rewards_min_size` | `number \| null` | Min order size eligible for rewards (USD). | | `native_daily_rate` | `number \| null` | Native (Polymarket) daily rate. | | `sponsored_daily_rate` | `number \| null` | Sponsored daily rate. | | `total_daily_rate` | `number \| null` | Combined daily rate. | | `sponsors_count` | `integer \| null` | Number of active sponsors. | #### Example ```json theme={null} { "type": "markets_stream_update", "room_id": "polymarket_markets_stream", "mode": "ids", "interval_ms": 500, "data": [ { "condition_id": "0xabc123...", "id": "m_1", "market_slug": "will-bitcoin-hit-100k", "question": "Will Bitcoin hit $100k by Dec 31, 2026?", "title": "Bitcoin $100k by EOY 2026", "description": "Resolves YES if BTC closes above $100,000 on any day before Dec 31, 2026.", "image_url": "https://cdn.struct.to/markets/btc-100k.png", "oracle": "0xoracle...", "status": "open", "created_time": 1743400000, "start_time": 1743500000, "game_start_time": null, "closed_time": null, "end_time": 1798761600, "accepting_orders": true, "uma_resolution_status": null, "is_neg_risk": false, "market_maker_address": "0xmm...", "creator": "0xcreator...", "category": "crypto", "volume_usd": 125000.5, "liquidity_usd": 42000.0, "highest_probability": 0.65, "total_holders": 312, "total_daily_rate": 150.0, "winning_outcome": null, "outcomes": [ { "name": "Yes", "price": 0.65, "position_id": "12345678901234567", "outcome_index": 0, "latest_block": 65000000, "latest_confirmed_at": 1743500000 }, { "name": "No", "price": 0.35, "position_id": "98765432109876543", "outcome_index": 1, "latest_block": 65000000, "latest_confirmed_at": 1743500000 } ], "clob_rewards": [ { "id": "1", "condition_id": "0xabc123...", "asset_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "rewards_amount": 18250.0, "rewards_daily_rate": 50.0, "start_date": "2026-01-01", "end_date": "2026-12-31", "rewards_max_spread": 0.04, "rewards_min_size": 20.0, "native_daily_rate": 100.0, "sponsored_daily_rate": 50.0, "total_daily_rate": 150.0, "sponsors_count": 1 } ], "tags": ["crypto", "bitcoin"], "event_slug": "bitcoin-price-markets", "resolution_source": "https://example.com/btc-resolution", "metrics": { "24h": { "volume": 3200.0, "fees": 6.4, "txns": 42, "unique_traders": 18 }, "7d": { "volume": 22000.0, "fees": 44.0, "txns": 310, "unique_traders": 121 } }, "relevance_score": null } ] } ``` # Oracle Events Source: https://docs.struct.to/websockets/rooms/oracle-events Stream typed UMA oracle lifecycle events for Polymarket markets. **Room ID:** `polymarket_oracle_events` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.002 credits per message Stream typed UMA OOv2/OOv3 oracle lifecycle events plus CTF and NegRisk adapter resolution events. Filter by event type, condition ID, market slug, or event slug. If no filters are provided the room subscribes to all oracle events. ## Subscribe All filters are optional. Combine multiple filters to narrow the stream. If all filters are empty and `subscribe_all` is false, the room automatically subscribes to all oracle events. ### Filters | Filter | Type | Required | Description | | -------------------- | ---------- | -------- | ----------------------------------------------------------------- | | `condition_ids` | `string[]` | No | Filter by 64-char hex condition IDs (with or without 0x prefix) | | `market_slugs` | `string[]` | No | Filter by market slug | | `event_slugs` | `string[]` | No | Filter by event slug (subscribes to all markets under each event) | | `oracle_event_types` | `string[]` | No | Restrict to specific event types (see list below) | | `status` | `string` | No | One of `confirmed` (default), `pending`, or `all` | | `subscribe_all` | `boolean` | No | Explicitly subscribe to all oracle events | #### Event types Valid values for `oracle_event_types`: `AssertionMade`, `AssertionDisputed`, `AssertionSettled`, `RequestPrice`, `ProposePrice`, `DisputePrice`, `Settle`, `QuestionResolved`, `QuestionEmergencyResolved`, `QuestionReset`, `QuestionInitialized`, `QuestionPaused`, `QuestionUnpaused`, `QuestionFlagged`, `QuestionUnflagged`, `ConditionResolution`, `NegRiskOutcomeReported`. ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_oracle_events" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_oracle_events", "message": { "action": "subscribe", "oracle_event_types": ["QuestionResolved", "ConditionResolution"], "condition_ids": ["0xabc123..."] } } } ``` ### Response ```json theme={null} { "type": "oracle_events_stream_subscribe_response", "room_id": "polymarket_oracle_events", "data": { "condition_ids": ["0xabc123..."], "market_slugs": [], "event_slugs": [], "oracle_event_types": ["QuestionResolved", "ConditionResolution"], "status": "confirmed", "subscribe_all": false, "rejected": [] } } ``` ## Events ### `oracle_event_update` The payload is a discriminated union based on the `event_type` field. Each variant carries its own fields. All variants share an on-chain envelope (`id`, `hash`, `block`, `confirmed_at`, `log_index`, `block_index`, `oracle_contract`) and may include the optional market metadata `condition_id`, `question`, `image_url`, `slug`, `event_slug`. #### AssertionMade UMA OOv3 truth assertion submitted. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "AssertionMade", "id": "0xassert123", "hash": "0xasserthash", "block": 65000700, "confirmed_at": 1743500700, "log_index": 1, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "assertion_id": "0x1111111111111111111111111111111111111111111111111111111111111111", "domain_id": "0x0000000000000000000000000000000000000000000000000000000000000000", "claim": "0x59657300000000000000000000000000000000000000000000000000000000", "asserter": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "callback_recipient": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "escalation_manager": "0x0000000000000000000000000000000000000000", "caller": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "expiration_time": 1743600000, "currency": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "bond": "5000000000", "identifier": "0x59455320204f5220204e4f000000000000000000000000000000000000000000", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "proposed_outcome": "Yes", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets" } } ``` #### AssertionDisputed UMA OOv3 assertion challenged within liveness. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "AssertionDisputed", "id": "0xdispute123", "hash": "0xdisputehash", "block": 65000800, "confirmed_at": 1743500800, "log_index": 4, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "assertion_id": "0x1111111111111111111111111111111111111111111111111111111111111111", "caller": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "disputer": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### AssertionSettled UMA OOv3 assertion finalized (liveness expired or DVM resolved). ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "AssertionSettled", "id": "0xsettle123", "hash": "0xsettlehash", "block": 65000900, "confirmed_at": 1743500900, "log_index": 2, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "assertion_id": "0x1111111111111111111111111111111111111111111111111111111111111111", "bond_recipient": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "disputed": false, "settlement_resolution": true, "settle_caller": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### RequestPrice UMA OOv2 price request created (resolution lookup begins). ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "RequestPrice", "id": "0xreq123", "hash": "0xreqhash", "block": 65001000, "confirmed_at": 1743501000, "log_index": 0, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "requester": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "identifier": "0x59455320204f5220204e4f000000000000000000000000000000000000000000", "timestamp": "1743501000", "ancillary_data": "0x713a2057696c6c20425443206869742024313030483f", "currency": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "reward": "0", "final_fee": "1500000000", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### ProposePrice UMA OOv2 outcome proposed by a proposer. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "ProposePrice", "id": "0xpropose123", "hash": "0xproposehash", "block": 65001100, "confirmed_at": 1743501100, "log_index": 1, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "requester": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "proposer": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "identifier": "0x59455320204f5220204e4f000000000000000000000000000000000000000000", "timestamp": "1743501000", "ancillary_data": "0x713a2057696c6c20425443206869742024313030483f", "proposed_price": 1000000000000000000, "expiration_timestamp": "1743600000", "currency": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "proposed_outcome": "Yes" } } ``` #### DisputePrice UMA OOv2 proposal challenged. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "DisputePrice", "id": "0xdispprice123", "hash": "0xdispricehash", "block": 65001200, "confirmed_at": 1743501200, "log_index": 3, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "requester": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "proposer": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "disputer": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "identifier": "0x59455320204f5220204e4f000000000000000000000000000000000000000000", "timestamp": "1743501000", "ancillary_data": "0x713a2057696c6c20425443206869742024313030483f", "proposed_price": 1000000000000000000, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### Settle UMA OOv2 request settled (proposer or DVM payout). ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "Settle", "id": "0xs123", "hash": "0xshash", "block": 65001300, "confirmed_at": 1743501300, "log_index": 1, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "requester": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", "proposer": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "disputer": "0x0000000000000000000000000000000000000000", "identifier": "0x59455320204f5220204e4f000000000000000000000000000000000000000000", "timestamp": "1743501000", "ancillary_data": "0x713a2057696c6c20425443206869742024313030483f", "proposed_price": 1000000000000000000, "payout": "5000000000", "disputed": false, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### QuestionResolved UMA CTF Adapter: market resolved with definitive outcome. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionResolved", "id": "0xresolved123", "hash": "0xresolvedhash", "block": 65001400, "confirmed_at": 1743501400, "log_index": 2, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "settled_price": 1000000000000000000, "proposed_outcome": "Yes", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets" } } ``` #### QuestionEmergencyResolved UMA CTF Adapter: admin emergency resolution. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionEmergencyResolved", "id": "0xemerg123", "hash": "0xemerghash", "block": 65001500, "confirmed_at": 1743501500, "log_index": 0, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "proposed_outcome": "No" } } ``` #### QuestionReset UMA CTF Adapter: dispute succeeded, market returns to active. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionReset", "id": "0xreset123", "hash": "0xresethash", "block": 65001600, "confirmed_at": 1743501600, "log_index": 0, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### QuestionInitialized UMA CTF Adapter: questionID first initialized on-chain. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionInitialized", "id": "0xinit123", "hash": "0xinithash", "block": 65001700, "confirmed_at": 1743501700, "log_index": 1, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "creator": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "reward_token": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "reward": "0", "proposal_bond": "5000000000" } } ``` #### QuestionPaused / QuestionUnpaused UMA CTF Adapter: market paused or unpaused by admin. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionPaused", "id": "0xpause123", "hash": "0xpausehash", "block": 65001800, "confirmed_at": 1743501800, "log_index": 0, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### QuestionFlagged / QuestionUnflagged UMA CTF Adapter: market flagged for emergency resolution, or flag removed. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "QuestionFlagged", "id": "0xflag123", "hash": "0xflaghash", "block": 65002000, "confirmed_at": 1743502000, "log_index": 0, "block_index": 0, "oracle_contract": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } } ``` #### ConditionResolution CTF: positions become redeemable. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "ConditionResolution", "id": "0xcondres123", "hash": "0xcondreshash", "block": 65002200, "confirmed_at": 1743502200, "log_index": 5, "block_index": 0, "oracle_contract": "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "oracle": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", "proposed_outcome": "Yes" } } ``` #### NegRiskOutcomeReported NegRisk Adapter: neg-risk market outcome determined. ```json theme={null} { "type": "oracle_event_update", "room_id": "polymarket_oracle_events", "status": "confirmed", "data": { "event_type": "NegRiskOutcomeReported", "id": "0xnegrisk123", "hash": "0xnegriskhash", "block": 65002300, "confirmed_at": 1743502300, "log_index": 0, "block_index": 0, "oracle_contract": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "proposed_outcome": "Yes" } } ``` **Pending events:** With `status: "pending"` or `status: "all"`, mempool entries are delivered as soon as the transaction is seen. Fields unavailable from mempool (`block`, `confirmed_at`, `log_index`, `block_index`) are omitted, and `received_at` (Unix milliseconds) is included instead. # Order Book Source: https://docs.struct.to/websockets/rooms/order-book Stream CLOB order book snapshots. **Room ID:** `polymarket_order_book` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.001 credits per message Stream CLOB order book snapshots for specific markets or outcome positions. At least one filter is required. The combined total of `condition_ids` and `position_ids` must not exceed 500. ## Subscribe At least one of `condition_ids` or `position_ids` is required. Without any filters, the room will not emit events. You can combine both filters to narrow the stream. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `condition_ids` | `string[]` | No | Markets to stream order books for | | `position_ids` | `string[]` | No | Outcome token IDs to stream order books for | | `subscribe_all` | `boolean` | No | Firehose: receive every market's snapshots, delivered as conflated `order_book_batch` envelopes every 50ms. Filters are ignored when set | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_order_book" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_order_book", "message": { "action": "subscribe", "condition_ids": ["0xabc123..."] } } } ``` ### Response ```json theme={null} { "type": "order_book_stream_subscribe_response", "room_id": "polymarket_order_book", "data": { "condition_ids": ["0xabc123..."], "position_ids": [], "rejected": [] } } ``` ## Events ### `order_book_update` ```json theme={null} { "type": "order_book_update", "room_id": "polymarket_order_book", "data": { "asset_id": "12345678901234567", "market": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "bids": [{ "p": "0.60", "s": "500" }, { "p": "0.59", "s": "1200" }, { "p": "0.58", "s": "3000" }], "asks": [{ "p": "0.61", "s": "800" }, { "p": "0.62", "s": "1500" }, { "p": "0.63", "s": "2500" }], "timestamp": 1743500000000, "hash": "abc123def456", "best_bid": 0.60, "best_ask": 0.61, "mid_price": 0.605, "spread": 0.01, "bid_liquidity_usd": 4500.0, "ask_liquidity_usd": 5200.0, "bid_levels": 3, "ask_levels": 3 } } ``` Each entry in `bids` and `asks` is a price level object: | Field | Type | Description | | ----- | -------- | ----------------------------------- | | `p` | `string` | Price (0–1), as a decimal string | | `s` | `string` | Size in shares, as a decimal string | `bids` are sorted best-first (highest price first); `asks` are sorted best-first (lowest price first). ### `order_book_batch` In firehose mode (`subscribe_all: true`), snapshots are conflated into `order_book_batch` envelopes emitted every 50ms instead of a message per book change. `count` is the number of snapshots in the batch, and each entry in `data` has the same shape as the `order_book_update` `data` object. ```json theme={null} { "type": "order_book_batch", "room_id": "polymarket_order_book", "count": 2, "data": [ { "asset_id": "25372132047423243393274625033211281816817614495645323101576308577715572160691", "market": "0x1238ad393b16d730f09210caee750695b12e27090d2d56589bb45f78aa92c031", "bids": [{ "p": "0.983", "s": "11.41" }, { "p": "0.982", "s": "70" }, { "p": "0.981", "s": "140" }], "asks": [{ "p": "0.999", "s": "7308.15" }], "timestamp": 1783971763067, "hash": "4cc4abf3723bbbe90d7ccbce39324b12bd24be91", "best_bid": 0.983, "best_ask": 0.999, "mid_price": 0.991, "spread": 0.016, "bid_liquidity_usd": 2501.15777, "ask_liquidity_usd": 7300.84185, "bid_levels": 54, "ask_levels": 1 }, { "asset_id": "77346748644272683089734212154044987565539531010435964787371832114367915821100", "market": "0x1238ad393b16d730f09210caee750695b12e27090d2d56589bb45f78aa92c031", "bids": [{ "p": "0.001", "s": "7308.15" }], "asks": [{ "p": "0.017", "s": "11.41" }, { "p": "0.018", "s": "70" }, { "p": "0.019", "s": "140" }], "timestamp": 1783971763067, "hash": "ee1c4a2594a54b9ab1f09849eb0e84deffbe0bfb", "best_bid": 0.001, "best_ask": 0.017, "mid_price": 0.009, "spread": 0.016, "bid_liquidity_usd": 7.30815, "ask_liquidity_usd": 15971.74223, "bid_levels": 1, "ask_levels": 54 } ] } ``` # Position Metrics Source: https://docs.struct.to/websockets/rooms/position-metrics Stream OHLC, volume, and probability metrics for individual outcome positions. **Room ID:** `polymarket_position_metrics` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream OHLC, volume, and probability metrics for individual outcome positions. Updates are pushed whenever position-level metrics change within the tracked timeframe windows. **Related guide:** [TradingView charts for Polymarket](/guides/tradingview-charts) uses this room for server-aggregated OHLC and probability bars. ## Subscribe The `position_ids` filter is required. You may optionally specify `timeframes` to limit which windows you receive. A maximum of 100 filters is allowed per client. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `position_ids` | `string[]` | Yes | Outcome token IDs to track | | `timeframes` | `string[]` | No | Timeframe windows to receive updates for | | `subscribe_all` | `boolean` | No | Firehose: receive metrics for every position. `position_ids` are ignored when set; the `timeframes` filter still applies | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_position_metrics" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_position_metrics", "message": { "action": "subscribe", "position_ids": ["12345"] } } } ``` ### Response ```json theme={null} { "type": "position_metrics_stream_subscribe_response", "room_id": "polymarket_position_metrics", "data": { "position_ids": ["12345"], "timeframes": [], "rejected": [] } } ``` ## Events ### `position_metrics_update` **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. USD and shares volumes are decimal strings to preserve precision. Builder-attributed fields cover the subset of activity routed through builder-signed orders, with separate buy and sell breakdowns. ```json theme={null} { "type": "position_metrics_update", "room_id": "polymarket_position_metrics", "data": { "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "position_id": "12345678901234567", "outcome": "Yes", "outcome_index": 0, "timeframe": "1h", "timestamp": 1743500000, "usd_volume": "45000.00", "usd_buy_volume": "28000.00", "usd_sell_volume": "17000.00", "shares_volume": "69200.00", "shares_buy_volume": "43000.00", "shares_sell_volume": "26200.00", "builder_usd_volume": "11000.00", "builder_usd_buy_volume": "7000.00", "builder_usd_sell_volume": "4000.00", "builder_shares_volume": "16900.00", "builder_shares_buy_volume": "10700.00", "builder_shares_sell_volume": "6200.00", "fees": 90.0, "builder_fees": 22.0, "txns": 120, "buys": 80, "sells": 40, "builder_txns": 30, "builder_buys": 20, "builder_sells": 10, "unique_traders": 35, "unique_builder_traders": 9, "price_open": 0.62, "price_close": 0.65, "price_high": 0.67, "price_low": 0.60, "probability_open": 0.62, "probability_close": 0.65, "probability_high": 0.67, "probability_low": 0.60, "avg_trade_shares": 576.67, "avg_buy_shares": 537.5, "avg_sell_shares": 655.0 } } ``` **`price_*` and `probability_*` are different series.** `price_*` tracks the outcome token you're looking at (filter: `min_price_change_pct`); `probability_*` always tracks the Yes/Up token, index `0` (filter: `min_probability_change_pct`). They agree on the Yes side; on the No side they are complements (a `price` of `0.40` is a `probability` of `0.60`). # Tag Metrics Source: https://docs.struct.to/websockets/rooms/tag-metrics Stream aggregated volume and transaction metrics rolled up by tag. **Room ID:** `polymarket_tag_metrics` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream real-time volume, fee, and activity metrics aggregated by tag, across multiple timeframes. One event is pushed per timeframe window on each update, with a separate breakdown for builder-attributed activity. ## Subscribe The `tags` filter is required and must be non-empty. Tags are matched case-insensitively against either label or slug. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | `tags` | `string[]` | Yes | Tag labels or slugs to track | | `subscribe_all` | `boolean` | No | Firehose: receive metrics for every tag. The `tags` filter is ignored when set; the `timeframes` filter still applies | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_tag_metrics" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_tag_metrics", "message": { "action": "subscribe", "tags": ["Politics", "Crypto"] } } } ``` ### Response ```json theme={null} { "type": "tag_metrics_stream_subscribe_response", "room_id": "polymarket_tag_metrics", "data": { "tags": ["politics", "crypto"], "rejected": [], "error": null } } ``` ## Events ### `tag_metrics_update` **Volume on Polymarket vs. Struct.** Polymarket's UI reports "volume" as notional (the count of shares or contracts traded), which corresponds to `shares_volume` in Struct payloads. `usd_volume` is the dollar value of those trades. Use `shares_volume` to match figures shown on Polymarket and `usd_volume` for true USD throughput. Emitted once per timeframe window each time the underlying tag metrics change. Timeframe values: `1m`, `5m`, `30m`, `1h`, `6h`, `24h`, `7d`, `30d`. ```json theme={null} { "type": "tag_metrics_update", "room_id": "polymarket_tag_metrics", "data": { "tag": "politics", "timeframe": "1h", "timestamp": 1743500000, "usd_volume": "120000.00", "shares_volume": "148000.00", "builder_usd_volume": "30000.00", "builder_shares_volume": "37000.00", "fees": 600.0, "builder_fees": 150.0, "txns": 740, "builder_txns": 185, "unique_traders": 210, "unique_builder_traders": 54 } } ``` USD and shares volumes are decimal strings to preserve precision. Builder-attributed fields cover the subset of activity routed through builder-signed orders. # Trader Exit Markers Source: https://docs.struct.to/websockets/rooms/trader-exit-markers Stream one marker per position close for tracked traders. **Room ID:** `polymarket_trader_pnl_exits` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.025 credits per message Stream one marker per position close for tracked traders, batched per block. Each marker captures the realized PnL and the reason a position closed, so you can overlay exits directly onto a PnL chart. **Related guide:** [PnL chart with exit markers](/guides/pnl-chart-with-exit-markers) plots realized PnL and annotates each close with its exit reason. ## Exit reasons | Reason | Meaning | | --------------- | --------------------------------------------------- | | `resolved_win` | Held to market resolution and won by verdict | | `resolved_loss` | Held to market resolution and lost by verdict | | `sold_win` | Closed before resolution with positive realized PnL | | `sold_loss` | Closed before resolution with negative realized PnL | ## Subscribe The `traders` filter is required. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `traders` | `string[]` | Yes | EVM wallet addresses (0x-prefixed), non-empty | | `reasons` | `string[]` | No | Subset of `["resolved_win","resolved_loss","sold_win","sold_loss"]`, or `["all"]` | | `subscribe_all` | `boolean` | No | Firehose: receive exits for every trader. The `traders` filter is ignored when set; `reasons` still applies | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_trader_pnl_exits" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_pnl_exits", "message": { "action": "subscribe", "traders": ["0x1234567890abcdef1234567890abcdef12345678"] } } } ``` ### Response ```json theme={null} { "type": "trader_exit_markers_stream_subscribe_response", "room_id": "polymarket_trader_pnl_exits", "data": { "traders": ["0x1234567890abcdef1234567890abcdef12345678"], "rejected": [], "error": null } } ``` ## Events ### `trader_exit_marker_batch` One marker per position close. ```json theme={null} { "type": "trader_exit_marker_batch", "room_id": "polymarket_trader_pnl_exits", "block": 68420001, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "position_id": "12345678901234567", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "event_slug": "bitcoin-price-markets", "market_slug": "will-bitcoin-hit-100k", "title": "Will Bitcoin hit $100k?", "question": "Will Bitcoin hit $100k by end of year?", "image_url": "https://polymarket.com/images/market.png", "outcome": "Yes", "outcome_index": 0, "pnl_usd": 350.25, "pnl_pct": 35.9, "cost_basis_usd": 975.0, "reason": "sold_win", "block": 68420001, "ts": 1743500000 } ] } ``` | Field | Type | Description | | ---------------- | ------------- | ----------------------------------------- | | `trader` | `string` | Wallet address | | `position_id` | `string` | ERC-1155 token ID (decimal string) | | `condition_id` | `string` | Condition ID | | `event_slug` | `string` | Owning event slug | | `market_slug` | `string` | Market slug | | `title` | `string` | Market title | | `question` | `string` | Market question | | `image_url` | `string` | Market image URL | | `outcome` | `string` | Outcome label (for example `Yes`) | | `outcome_index` | `int \| null` | Outcome index | | `pnl_usd` | `number` | Realized PnL at exit, in USD | | `pnl_pct` | `number` | Realized PnL as a percentage | | `cost_basis_usd` | `number` | Cost basis of the closed position, in USD | | `reason` | `string` | Exit reason (see taxonomy above) | | `block` | `int64` | Block of the exit | | `ts` | `int64` | Exit timestamp (Unix seconds) | # Trader PnL Source: https://docs.struct.to/websockets/rooms/trader-pnl Stream global, market, and category PnL for tracked traders across rolling windows. **Room ID:** `polymarket_trader_pnl` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.1 credits per message Stream profit and loss for tracked traders. PnL is computed at three grains and maintained over four rolling windows, so you can drive a full portfolio view from a single subscription. The three grains are `global` (the whole portfolio), `market` (per condition), and `category` (per market category). Each grain is tracked over four windows: `1d`, `7d`, `30d`, and `lifetime`. **Related guide:** [Live trader PnL dashboard](/guides/live-trader-pnl-dashboard) builds a complete realized PnL view from this room. ## Event families Each grain emits three families of events. A given dirty row goes to exactly one family per block, so the families are disjoint. | Family | Events | Trigger | Timeframe | | -------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------- | | Full-row batch | `trader_global_pnl_batch`, `trader_market_pnl_batch`, `trader_category_pnl_batch` | Trade landed or a rolling window boundary crossed | Carries a top-level `timeframe` | | Price tick | `trader_global_tick_batch`, `trader_market_tick_batch`, `trader_category_tick_batch` | Mark-to-market refresh (outcome price moved) | Window-agnostic | | Resolution | `trader_global_resolution_batch`, `trader_market_resolution_batch`, `trader_category_resolution_batch` | The owning market resolved with no trade | Window-agnostic | Updates are batched per block. Each envelope is shaped `{ type, room_id, block, [timeframe,] data: [ ...rows ] }`, with one envelope per grain, family, and (for full-row batches) timeframe per block. Empty batches are not sent. Every row carries a `dirty_kinds` array naming what triggered it. Tick and resolution rows are window-agnostic and carry boundary snapshots, so any per-window delta is recoverable client-side without waiting for a full-row batch. ### dirty\_kinds | Value | Meaning | | ----------------- | ---------------------------------------------------------------- | | `trade` | A buy, sell, merge, split, redemption, or NegRisk convert landed | | `price` | An outcome price moved (mark-to-market refresh) | | `window` | A 1d, 7d, or 30d rolling window boundary crossed | | `market_resolved` | The owning market resolved | ## Subscribe The `traders` filter is required. The remaining filters are optional and narrow the stream. ### Filters | Filter | Type | Required | Description | | -------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `traders` | `string[]` | Yes | EVM wallet addresses (0x-prefixed), non-empty. Invalid addresses are returned in `rejected`. | | `update_types` | `string[]` | No | Subset of `["global","market","category"]`. Omit or pass empty for all grains. | | `timeframes` | `string[]` | No | Subset of `["1d","7d","30d","lifetime"]`. Omit or pass empty for all windows. Ignored by tick and resolution families, which are window-agnostic. | | `dirty_kinds` | `string[]` | No | Subset of `["trade","price","window","market_resolved"]`, or `["all"]`. A row is delivered only if its `dirty_kinds` intersects this set. | An unknown value in `update_types`, `timeframes`, or `dirty_kinds` rejects the whole subscription with an `error`. ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_trader_pnl" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_pnl", "message": { "action": "subscribe", "traders": ["0x1234567890abcdef1234567890abcdef12345678"] } } } ``` ### Response The subscribe response (`trader_pnl_stream_subscribe_response`) echoes the resolved configuration. ```json theme={null} { "type": "trader_pnl_stream_subscribe_response", "room_id": "polymarket_trader_pnl", "data": { "traders": ["0x1234567890abcdef1234567890abcdef12345678"], "update_types": ["global", "market", "category"], "timeframes": ["1d", "7d", "30d", "lifetime"], "rejected": [], "error": null } } ``` ## Events ### `trader_global_pnl_batch` Full-row PnL across the whole portfolio. Carries a top-level `timeframe`. ```json theme={null} { "type": "trader_global_pnl_batch", "room_id": "polymarket_trader_pnl", "block": 68420001, "timeframe": "lifetime", "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "dirty_kinds": ["trade"], "timeframe": "lifetime", "realized_pnl_usd": 12500.75, "events_traded": 15, "markets_traded": 42, "total_buys": 120, "total_sells": 85, "total_redemptions": 12, "total_merges": 0, "total_splits": 0, "total_volume_usd": 450000.0, "buy_volume_usd": 230000.0, "sell_volume_usd": 180000.0, "redemption_volume_usd": 40000.0, "merge_volume_usd": 0.0, "split_volume_usd": 0.0, "maker_rebate_count": 4, "maker_rebate_usd": 12.5, "reward_count": 2, "reward_usd": 30.0, "yield_count": 0, "yield_usd": 0.0, "total_credit_count": 6, "total_credit_usd": 42.5, "markets_won": 28, "markets_lost": 14, "market_win_rate_pct": 66.67, "total_wins_usd": 18000.0, "total_losses_usd": 5499.25, "avg_win_usd": 642.86, "avg_loss_usd": 392.8, "profit_factor": 3.27, "avg_hold_time_seconds": 172800.0, "total_fees": 900.0, "best_trade_pnl_usd": 5200.0, "best_trade_condition_id": "0x1234...cdef", "worst_trade_pnl_usd": -1200.0, "worst_trade_condition_id": null, "first_trade_at": 1740000000, "last_trade_at": 1743500000 } ] } ``` | Field | Type | Description | | -------------------------- | ---------------- | ------------------------------------- | | `trader` | `string` | Wallet address | | `dirty_kinds` | `string[]` | What triggered this row | | `timeframe` | `string \| null` | Window this row covers | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `events_traded` | `int64` | Distinct events traded | | `markets_traded` | `int64` | Distinct markets traded | | `total_buys` | `int64` | Buy count | | `total_sells` | `int64` | Sell count | | `total_redemptions` | `int64` | Redemption count | | `total_merges` | `int64` | Merge count | | `total_splits` | `int64` | Split count | | `total_volume_usd` | `number` | Total traded volume in USD | | `buy_volume_usd` | `number` | Buy volume in USD | | `sell_volume_usd` | `number` | Sell volume in USD | | `redemption_volume_usd` | `number` | Redemption volume in USD | | `merge_volume_usd` | `number` | Merge volume in USD | | `split_volume_usd` | `number` | Split volume in USD | | `maker_rebate_count` | `int64` | Maker rebate count | | `maker_rebate_usd` | `number` | Maker rebate total in USD | | `reward_count` | `int64` | Reward count | | `reward_usd` | `number` | Reward total in USD | | `yield_count` | `int64` | Yield credit count | | `yield_usd` | `number` | Yield total in USD | | `total_credit_count` | `int64` | Combined credit count | | `total_credit_usd` | `number` | Combined credit total in USD | | `markets_won` | `int64` | Markets won | | `markets_lost` | `int64` | Markets lost | | `market_win_rate_pct` | `number` | Win rate as a percentage | | `total_wins_usd` | `number` | Sum of winning PnL | | `total_losses_usd` | `number` | Sum of losing PnL | | `avg_win_usd` | `number` | Average winning PnL | | `avg_loss_usd` | `number` | Average losing PnL | | `profit_factor` | `number \| null` | Gross wins divided by gross losses | | `avg_hold_time_seconds` | `number` | Average position hold time in seconds | | `total_fees` | `number` | Total fees in USD | | `best_trade_pnl_usd` | `number \| null` | Best single-trade PnL | | `best_trade_condition_id` | `string \| null` | Condition of the best trade | | `worst_trade_pnl_usd` | `number \| null` | Worst single-trade PnL | | `worst_trade_condition_id` | `string \| null` | Condition of the worst trade | | `first_trade_at` | `int64 \| null` | First trade (Unix seconds) | | `last_trade_at` | `int64 \| null` | Last trade (Unix seconds) | ### `trader_market_pnl_batch` Full-row PnL for a single market (condition). Carries a top-level `timeframe`. ```json theme={null} { "type": "trader_market_pnl_batch", "room_id": "polymarket_trader_pnl", "block": 68420001, "timeframe": "lifetime", "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "dirty_kinds": ["trade"], "timeframe": "lifetime", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "realized_pnl_usd": 350.25, "current_shares_balance": 1000.0, "category": "Crypto", "event_slug": "bitcoin-price-markets", "outcomes_traded": 2, "total_buys": 5, "total_sells": 3, "total_redemptions": 0, "total_merges": 0, "total_splits": 0, "buy_usd": 2000.0, "sell_usd": 2350.25, "redemption_usd": 0.0, "merge_usd": 0.0, "split_volume_usd": 0.0, "total_fees": 8.7, "total_shares_bought": 3000.0, "first_trade_at": 1742000000, "last_trade_at": 1743500000 } ] } ``` | Field | Type | Description | | ------------------------ | ---------------- | -------------------------- | | `trader` | `string` | Wallet address | | `dirty_kinds` | `string[]` | What triggered this row | | `timeframe` | `string \| null` | Window this row covers | | `condition_id` | `string` | 64-char hex condition ID | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `current_shares_balance` | `number` | Current shares held | | `category` | `string \| null` | Market category | | `event_slug` | `string \| null` | Owning event slug | | `outcomes_traded` | `int64` | Distinct outcomes traded | | `total_buys` | `int64` | Buy count | | `total_sells` | `int64` | Sell count | | `total_redemptions` | `int64` | Redemption count | | `total_merges` | `int64` | Merge count | | `total_splits` | `int64` | Split count | | `buy_usd` | `number` | Buy volume in USD | | `sell_usd` | `number` | Sell volume in USD | | `redemption_usd` | `number` | Redemption volume in USD | | `merge_usd` | `number` | Merge volume in USD | | `split_volume_usd` | `number` | Split volume in USD | | `total_fees` | `number` | Total fees in USD | | `total_shares_bought` | `number` | Cumulative shares bought | | `first_trade_at` | `int64 \| null` | First trade (Unix seconds) | | `last_trade_at` | `int64 \| null` | Last trade (Unix seconds) | ### `trader_category_pnl_batch` Full-row PnL for a market category. Carries a top-level `timeframe`. ```json theme={null} { "type": "trader_category_pnl_batch", "room_id": "polymarket_trader_pnl", "block": 68420001, "timeframe": "lifetime", "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "dirty_kinds": ["trade"], "timeframe": "lifetime", "category": "Crypto", "realized_pnl_usd": 4200.0, "markets_in_category": 18, "markets_traded": 12, "outcomes_traded": 20, "total_buys": 40, "total_sells": 28, "total_redemptions": 4, "total_merges": 0, "total_splits": 0, "total_volume_usd": 90000.0, "buy_usd": 50000.0, "sell_usd": 35000.0, "redemption_usd": 5000.0, "merge_usd": 0.0, "split_volume_usd": 0.0, "total_fees": 180.0, "total_shares_bought": 60000.0, "markets_won": 8, "markets_lost": 4, "market_win_rate_pct": 66.67, "avg_hold_time_seconds": 150000.0, "best_trade_pnl_usd": 2100.0, "best_trade_condition_id": "0x1234...cdef", "worst_trade_pnl_usd": -600.0, "worst_trade_condition_id": null, "total_wins_usd": 6000.0, "total_losses_usd": 1800.0, "avg_win_usd": 750.0, "avg_loss_usd": 450.0, "profit_factor": 3.33, "first_trade_at": 1741000000, "last_trade_at": 1743500000 } ] } ``` | Field | Type | Description | | -------------------------- | ---------------- | ---------------------------------- | | `trader` | `string` | Wallet address | | `dirty_kinds` | `string[]` | What triggered this row | | `timeframe` | `string \| null` | Window this row covers | | `category` | `string` | Market category | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `markets_in_category` | `int64` | Markets available in the category | | `markets_traded` | `int64` | Markets traded in the category | | `outcomes_traded` | `int64` | Distinct outcomes traded | | `total_buys` | `int64` | Buy count | | `total_sells` | `int64` | Sell count | | `total_redemptions` | `int64` | Redemption count | | `total_merges` | `int64` | Merge count | | `total_splits` | `int64` | Split count | | `total_volume_usd` | `number` | Total traded volume in USD | | `buy_usd` | `number` | Buy volume in USD | | `sell_usd` | `number` | Sell volume in USD | | `redemption_usd` | `number` | Redemption volume in USD | | `merge_usd` | `number` | Merge volume in USD | | `split_volume_usd` | `number` | Split volume in USD | | `total_fees` | `number` | Total fees in USD | | `total_shares_bought` | `number` | Cumulative shares bought | | `markets_won` | `int64` | Markets won | | `markets_lost` | `int64` | Markets lost | | `market_win_rate_pct` | `number` | Win rate as a percentage | | `avg_hold_time_seconds` | `number` | Average hold time in seconds | | `best_trade_pnl_usd` | `number \| null` | Best single-trade PnL | | `best_trade_condition_id` | `string \| null` | Condition of the best trade | | `worst_trade_pnl_usd` | `number \| null` | Worst single-trade PnL | | `worst_trade_condition_id` | `string \| null` | Condition of the worst trade | | `total_wins_usd` | `number` | Sum of winning PnL | | `total_losses_usd` | `number` | Sum of losing PnL | | `avg_win_usd` | `number` | Average winning PnL | | `avg_loss_usd` | `number` | Average losing PnL | | `profit_factor` | `number \| null` | Gross wins divided by gross losses | | `first_trade_at` | `int64 \| null` | First trade (Unix seconds) | | `last_trade_at` | `int64 \| null` | Last trade (Unix seconds) | ### Price tick batches `trader_global_tick_batch`, `trader_market_tick_batch`, and `trader_category_tick_batch` carry mark-to-market refreshes. They are window-agnostic and their `dirty_kinds` is always `["price"]`. ```json theme={null} { "type": "trader_global_tick_batch", "room_id": "polymarket_trader_pnl", "block": 68420002, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "realized_pnl_usd": 12500.75, "open_positions_value": 8400.0, "last_block": 68420002, "last_trade_at": 1743500000, "dirty_kinds": ["price"] } ] } ``` | Row | Fields | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `TraderGlobalTickRow` | `trader`, `realized_pnl_usd`, `open_positions_value`, `last_block` (`int64`), `last_trade_at` (`int64 \| null`), `dirty_kinds` | | `TraderMarketTickRow` | `trader`, `condition_id`, `realized_pnl_usd`, `last_block`, `last_trade_at`, `dirty_kinds` | | `TraderCategoryTickRow` | `trader`, `category`, `realized_pnl_usd`, `last_block`, `last_trade_at`, `dirty_kinds` | ### Resolution batches `trader_global_resolution_batch`, `trader_market_resolution_batch`, and `trader_category_resolution_batch` fire when an owning market resolves without a trade. They are window-agnostic and their `dirty_kinds` is always `["market_resolved"]`. ```json theme={null} { "type": "trader_market_resolution_batch", "room_id": "polymarket_trader_pnl", "block": 68420003, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "resolved": true, "won": true, "realized_pnl_usd": 350.25, "last_block": 68420003, "last_trade_at": 1743500000, "dirty_kinds": ["market_resolved"] } ] } ``` | Row | Fields | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TraderGlobalResolutionRow` | `trader`, `realized_pnl_usd`, `markets_won`, `markets_lost`, `markets_resolved` (`int64`), `total_wins_usd_lifetime`, `total_losses_usd_lifetime` (`number`), `last_block`, `last_trade_at`, `dirty_kinds` | | `TraderMarketResolutionRow` | `trader`, `condition_id`, `resolved` (`bool`), `won` (`bool \| null`), `realized_pnl_usd`, `last_block`, `last_trade_at`, `dirty_kinds` | | `TraderCategoryResolutionRow` | `trader`, `category`, `realized_pnl_usd`, `markets_won`, `markets_lost` (`int64`), `total_wins_usd_lifetime`, `total_losses_usd_lifetime`, `last_block`, `last_trade_at`, `dirty_kinds` | # Trader Positions Source: https://docs.struct.to/websockets/rooms/trader-positions Stream full position snapshots, price ticks, and resolutions for tracked traders. **Room ID:** `polymarket_trader_positions` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.005 credits per message Stream per-position updates for tracked traders, batched per block. The room emits three envelope types: full rows on trades, compact price ticks on mark-to-market refreshes, and resolution ticks when a position's market resolves. **Related guide:** [Real-time positions tracking](/guides/real-time-positions-tracking) seeds a portfolio from REST and keeps it live through this room. ## Envelope types | Event | Trigger | dirty\_kinds | | ---------------------------------- | ------------------------------ | -------------------------------- | | `trader_position_batch` | A trade landed | `["trade"]` (may include others) | | `trader_position_price_batch` | Mark-to-market refresh | `["price"]` | | `trader_position_resolution_batch` | The position's market resolved | `["position_resolved"]` | Each envelope is shaped `{ type, room_id, block, data: [ ...rows ] }`. Empty batches are not sent. Resolution rows cover lifetime non-redeemers as well as fresh resolutions. ### dirty\_kinds | Value | Meaning | | ------------------- | ---------------------------------------------------------------- | | `trade` | A buy, sell, merge, split, redemption, or NegRisk convert landed | | `price` | An outcome price moved (mark-to-market refresh) | | `position_resolved` | The position's market resolved | ## Subscribe The `traders` filter is required. ### Filters | Filter | Type | Required | Description | | ------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `traders` | `string[]` | Yes | EVM wallet addresses (0x-prefixed), non-empty | | `dirty_kinds` | `string[]` | No | Subset of `["trade","price","position_resolved"]`, or `["all"]`. A row is delivered only if its `dirty_kinds` intersects this set. | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_trader_positions" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trader_positions", "message": { "action": "subscribe", "traders": ["0x1234567890abcdef1234567890abcdef12345678"] } } } ``` ### Response ```json theme={null} { "type": "trader_positions_stream_subscribe_response", "room_id": "polymarket_trader_positions", "data": { "traders": ["0x1234567890abcdef1234567890abcdef12345678"], "rejected": [], "error": null } } ``` ## Events ### `trader_position_batch` Full position rows, fired on trades. ```json theme={null} { "type": "trader_position_batch", "room_id": "polymarket_trader_positions", "block": 68420001, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "position_id": "12345678901234567", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "market_slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "title": "Will Bitcoin hit $100k?", "question": "Will Bitcoin hit $100k by end of year?", "image_url": "https://polymarket.com/images/market.png", "outcome": "Yes", "outcome_index": 0, "open": true, "won": null, "total_buys": 3, "total_sells": 1, "converted_count": 0, "converted_shares_gained": 0.0, "converted_shares_lost": 0.0, "total_shares_bought": 1500.0, "total_shares_sold": 500.0, "total_buy_usd": 975.0, "total_sell_usd": 350.0, "redemption_usd": 0.0, "merge_usd": 0.0, "avg_entry_price": 0.65, "avg_exit_price": 0.7, "avg_price": 0.66, "realized_pnl_usd": 125.5, "realized_pnl_pct": 12.87, "total_fees": 2.65, "first_trade_at": 1742000000000, "last_trade_at": 1743500000000, "current_price": 0.72, "current_shares_balance": 1000.0, "current_value": 720.0, "last_traded_price": 0.71, "end_date": 1751328000, "is_neg_risk": false, "redeemable": false, "mergeable": false, "dirty_kinds": ["trade"] } ] } ``` All fields are nullable. | Field | Type | Description | | ------------------------- | ---------- | ---------------------------------------------- | | `position_id` | `string` | ERC-1155 token ID (decimal string) | | `condition_id` | `string` | Condition ID | | `market_slug` | `string` | Market slug | | `event_slug` | `string` | Owning event slug | | `title` | `string` | Market title | | `question` | `string` | Market question | | `image_url` | `string` | Market image URL | | `outcome` | `string` | Outcome label (for example `Yes`) | | `outcome_index` | `int` | Outcome index | | `open` | `bool` | Whether the position is open | | `won` | `bool` | Set once the market resolves | | `total_buys` | `int64` | Buy count | | `total_sells` | `int64` | Sell count | | `converted_count` | `int64` | NegRisk conversion count | | `converted_shares_gained` | `number` | Shares gained via conversion | | `converted_shares_lost` | `number` | Shares lost via conversion | | `total_shares_bought` | `number` | Cumulative shares bought | | `total_shares_sold` | `number` | Cumulative shares sold | | `total_buy_usd` | `number` | Buy spend in USD | | `total_sell_usd` | `number` | Sell proceeds in USD | | `redemption_usd` | `number` | Redemption proceeds in USD | | `merge_usd` | `number` | Merge proceeds in USD | | `avg_entry_price` | `number` | Volume-weighted entry price (0-1) | | `avg_exit_price` | `number` | Volume-weighted exit price (0-1) | | `avg_price` | `number` | Volume-weighted price across buys and sells | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `realized_pnl_pct` | `number` | Realized PnL as a percentage | | `total_fees` | `number` | Total fees in USD | | `first_trade_at` | `int64` | First trade (**Unix milliseconds**) | | `last_trade_at` | `int64` | Last trade (**Unix milliseconds**) | | `current_price` | `number` | Current outcome price | | `current_shares_balance` | `number` | Current shares held | | `current_value` | `number` | `current_price` times `current_shares_balance` | | `last_traded_price` | `number` | Last on-chain traded price | | `end_date` | `int64` | Resolution deadline (Unix seconds) | | `is_neg_risk` | `bool` | Whether the market is NegRisk | | `redeemable` | `bool` | Resolved and still holding shares | | `mergeable` | `bool` | NegRisk, unresolved, and holding shares | | `dirty_kinds` | `string[]` | What triggered this row | ### `trader_position_price_batch` Compact price ticks. `dirty_kinds` is always `["price"]`. ```json theme={null} { "type": "trader_position_price_batch", "room_id": "polymarket_trader_positions", "block": 68420002, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "position_id": "12345678901234567", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "current_price": 0.74, "current_value": 740.0, "realized_pnl_usd": 125.5, "realized_pnl_pct": 12.87, "last_block": 68420002, "last_trade_at": 1743500000000, "dirty_kinds": ["price"] } ] } ``` | Field | Type | Description | | ------------------ | ---------- | ---------------------------------- | | `trader` | `string` | Wallet address | | `position_id` | `string` | ERC-1155 token ID (decimal string) | | `condition_id` | `string` | Condition ID | | `current_price` | `number` | Current outcome price | | `current_value` | `number` | Current position value in USD | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `realized_pnl_pct` | `number` | Realized PnL as a percentage | | `last_block` | `int64` | Block of this tick | | `last_trade_at` | `int64` | Last trade (Unix milliseconds) | | `dirty_kinds` | `string[]` | Always `["price"]` | ### `trader_position_resolution_batch` Resolution ticks. `dirty_kinds` is always `["position_resolved"]`. ```json theme={null} { "type": "trader_position_resolution_batch", "room_id": "polymarket_trader_positions", "block": 68420003, "data": [ { "trader": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "position_id": "12345678901234567", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "resolved": true, "won": true, "realized_pnl_usd": 350.25, "realized_pnl_pct": 35.9, "last_block": 68420003, "last_trade_at": 1743500000000, "dirty_kinds": ["position_resolved"] } ] } ``` | Field | Type | Description | | ------------------ | -------------- | ---------------------------------- | | `trader` | `string` | Wallet address | | `position_id` | `string` | ERC-1155 token ID (decimal string) | | `condition_id` | `string` | Condition ID | | `resolved` | `bool` | Whether the market resolved | | `won` | `bool \| null` | Whether the position won | | `realized_pnl_usd` | `number` | Realized PnL in USD | | `realized_pnl_pct` | `number` | Realized PnL as a percentage | | `last_block` | `int64` | Block of this tick | | `last_trade_at` | `int64` | Last trade (Unix milliseconds) | | `dirty_kinds` | `string[]` | Always `["position_resolved"]` | # Trades Source: https://docs.struct.to/websockets/rooms/trades Stream real-time Polymarket trades. **Room ID:** `polymarket_trades` \ **Endpoint:** `wss://api.struct.to/ws` \ **Rate:** 0.002 credits per message Stream real-time Polymarket trades as they happen. All filters are optional, and if none are provided the room auto-subscribes to every trade. **Related guides:** [Mirroring a trader from the mempool](/guides/copy-trading-pending-or-confirmed-trades) uses this room's `status` filter for pending vs confirmed fills, and [TradingView charts for Polymarket](/guides/tradingview-charts) feeds it into live OHLC bars. ## Subscribe All filters are optional. You can combine multiple filters to narrow the stream. A maximum of 500 total filters is allowed per client. If all filters are empty and `subscribe_all` is false, the room automatically subscribes to all trades. ### Filters | Filter | Type | Required | Description | | --------------- | ---------- | -------- | ------------------------------------------------------------------------------------------ | | `condition_ids` | `string[]` | No | Filter by market condition IDs | | `market_slugs` | `string[]` | No | Filter by market slug | | `event_slugs` | `string[]` | No | Filter by event slug | | `position_ids` | `string[]` | No | Filter by outcome token IDs | | `traders` | `string[]` | No | Filter by wallet addresses (lowercase 0x-prefixed) | | `trade_types` | `string[]` | No | Filter by trade type: `OrderFilled`, `OrdersMatched`, `Redemption`, `Merge`, `Split`, etc. | | `status` | `string` | No | One of `confirmed` (default), `pending`, or `all` | | `subscribe_all` | `boolean` | No | Subscribe to every trade with no filters | ### Example ```json theme={null} { "type": "join_room", "payload": { "room_id": "polymarket_trades" } } ``` ```json theme={null} { "type": "room_message", "payload": { "room_id": "polymarket_trades", "message": { "action": "subscribe", "condition_ids": ["0xabc123..."] } } } ``` ### Response ```json theme={null} { "type": "trade_stream_subscribe_response", "room_id": "polymarket_trades", "data": { "condition_ids": ["0xabc123..."], "market_slugs": [], "event_slugs": [], "position_ids": [], "traders": [], "trade_types": [], "rejected": [] } } ``` ## Events ### `trade_stream_update` The payload is a discriminated union based on the `trade_type` field. Each variant includes different fields. Below is an example of the most common type, `OrderFilled`. #### OrderFilled / OrdersMatched ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "OrderFilled", "id": "0xabc123def456", "hash": "0xdeadbeef1234567890", "block": 65000000, "confirmed_at": 1743500000, "log_index": 42, "block_index": 3, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "taker": "0x1234567890abcdef1234567890abcdef12345678", "side": "Buy", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "position_id": "12345678901234567", "outcome": "Yes", "outcome_index": 0, "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "usd_amount": 1500.50, "shares_amount": 2307.69, "price": 0.65, "probability": 0.65, "fee": 3.0, "fee_shares": 4.62, "fee_pct": 0.002, "exchange": "NegRiskExchange", "builder_code": "0x0000000000000000000000000000000000000000000000000000000000000000", "builder_fee": 0.0 } } ``` **`price` and `probability` are not the same field:** * **`price`**: the outcome you are looking at (the token in `outcome_index` / `position_id`). * **`probability`**: always outcome index `0` (the Yes/Up token), no matter which outcome the payload is about. They are equal only when `outcome_index` is `0`. On the No side (index `1`) they are complements: a `price` of `0.40` pairs with a `probability` of `0.60`. `builder_code` is the lower-cased `0x...` bytes32 identifier of the integrator that brokered the order (CLOB v2 only). `0x0000…` means the order was placed directly through Polymarket. Both `builder_code` and `builder_fee` are omitted on v1 trades. See [Fetching trades by builder code](/guides/fetching-trades-by-builder-code). #### Redemption ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "Redemption", "id": "0xredeem123", "hash": "0xredeemhash", "block": 65000100, "confirmed_at": 1743500100, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "outcome": "Yes", "outcome_index": 0, "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "usd_amount": 1000.0, "winning_outcome_index": 0, "position_details": [ { "position_id": "12345678901234567", "outcome_index": 0, "outcome": "Yes", "amount": "1000" } ], "exchange": "NegRiskExchange" } } ``` #### Merge ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "Merge", "id": "0xmerge123", "hash": "0xmergehash", "block": 65000200, "confirmed_at": 1743500200, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "usd_amount": 500.0, "position_details": [ { "position_id": "12345678901234567", "outcome_index": 0, "outcome": "Yes", "amount": "500" }, { "position_id": "98765432109876543", "outcome_index": 1, "outcome": "No", "amount": "500" } ], "exchange": "NegRiskExchange" } } ``` #### Split ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "Split", "id": "0xsplit123", "hash": "0xsplithash", "block": 65000300, "confirmed_at": 1743500300, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "usd_amount": 750.0, "position_details": [ { "position_id": "12345678901234567", "outcome_index": 0, "outcome": "Yes", "amount": "750" }, { "position_id": "98765432109876543", "outcome_index": 1, "outcome": "No", "amount": "750" } ], "exchange": "NegRiskExchange" } } ``` #### PositionsConverted ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "PositionsConverted", "id": "0xconvert123", "hash": "0xconverthash", "block": 65000400, "confirmed_at": 1743500400, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "market_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "index_set": "3", "shares_amount": 200.0, "exchange": "NegRiskExchange" } } ``` #### Cancelled ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "Cancelled", "id": "0xcancel123", "hash": "0xcancelhash", "block": 65000500, "confirmed_at": 1743500500, "order_hash": "0xorderhash123", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "exchange": "NegRiskExchange" } } ``` #### Oracle Lifecycle Covers `Initialization`, `Proposal`, `Dispute`, `Settled`, `Resolution`, `ConditionResolution`, `Reset`, `Flag`, `Unflag`, `Pause`, `Unpause`, `ManualResolution`, and `NegRiskOutcomeReported`. ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "Resolution", "id": "0xoracle123", "hash": "0xoraclehash", "block": 65000600, "confirmed_at": 1743500600, "oracle_contract": "0xoraclecontract", "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "slug": "will-bitcoin-hit-100k", "image_url": "https://example.com/markets/bitcoin-100k.png", "assertion_id": "0xassertion123", "settled_price": 1, "settlement_resolution": true } } ``` #### RegisterToken ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "RegisterToken", "id": "0xregtoken123", "hash": "0xregtokenhash", "block": 65000700, "confirmed_at": 1743500700, "condition_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "token0": "0xtoken0address", "token1": "0xtoken1address", "slug": "will-bitcoin-hit-100k", "event_slug": "bitcoin-price-markets", "image_url": "https://example.com/markets/bitcoin-100k.png", "exchange": "NegRiskExchange" } } ``` #### MakerRebate / Reward / Yield pUSD credit paid directly to a trader wallet. The three variants share the same payload shape and differ only by `trade_type`. `shares_amount`, `price`, `fee`, `fee_shares`, and `fee_pct` are always `0` for payout credits, and `taker` is the payout distributor address rather than a counterparty. ```json theme={null} { "type": "trade_stream_update", "room_id": "polymarket_trades", "status": "confirmed", "data": { "trade_type": "MakerRebate", "id": "0xrebate123", "hash": "0xrebatehash", "block": 65000800, "confirmed_at": 1743500800, "log_index": 12, "block_index": 1, "trader": { "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "name": null, "pseudonym": "vitalik.eth", "profile_image": null, "x_username": null, "verified_badge": false }, "taker": "0xpayoutdistributoraddress", "usd_amount": 12.5, "shares_amount": 0, "price": 0, "fee": 0, "fee_shares": 0, "fee_pct": 0, "exchange": 0 } } ``` **Pending trades:** Fields unavailable from mempool (`block`, `confirmed_at`, `log_index`, `block_index`, `order_hash`, `taker`, `fee`, `fee_shares`, `fee_pct`) are omitted. `received_at` (Unix milliseconds) is included instead.