Skip to main content
A sports game’s markets are spread across companion events (moneyline, spreads, totals, props) and venues, all sharing one canonical game key (aggKey, e.g. agg_fifwc_bih_che_260618). Build the detail page with three steps and no client-side matching.
This page needs exactly one piece of string parsing: the spread/total ladder family key. Everything else — tabs, labels, ordering, periods, lines, prices, cross-venue identity — comes from typed fields (marketGroup, sportsMarketType, period, lineValue, sectionRank, matchedVenueMarkets[]). Treat aggKey as an opaque filter value everywhere else; see Do you need to parse it?.

Listing the games (one tile per game)

GET /venue-events?grouped=true returns one tile per game: a venue’s companion events (… - More Markets, … - Exact Score, … : Spread) fold under their base, and cross-venue siblings collapse to the cluster anchor. Tap a tile, take its id, and load the detail page below. Each tile carries the game aggKey plus two counts: groupMarketCount covers that venue’s companion group, marketCount is cluster-scoped on the by-id response. Neither counts the whole game across companions and venues — only the aggKey query in step 2 does.
/venue-events takes event-level filters only (categoryIds, status, venue, aggKey, grouped, …). Market-level filters — marketGroup, sportsMarketType, period, marketCategory — live on /venue-markets; unknown query params on /venue-events are ignored, not rejected, so /venue-events?marketGroup=… silently returns every event in the category.
grouped is opt-in — only pass grouped=true once you have the game-detail page below, because grouping hides the companion events from the listing and the detail page is what re-surfaces their markets (spreads, exact score, props) via aggKey. Omit grouped (the default) and companion events list as separate tiles.

1. Resolve the game (any member event → the same page)

GET /venue-events/:id → read structureType (sport → render the game page) and aggKey (the game handle).
The page is keyed on the game (aggKey), not on the event you opened. A game’s companion events (… - Halftime Result, … - Exact Score, … : Spread) each have their own id but share the same aggKey. Opening any of them must render the same game page. Derive the tabs and markets below from the aggKey — NOT from the opened event’s own venueMarkets. If you key off the opened event, the base event shows only game lines and the “Halftime Result” companion shows only halves, even though both are the same game.

2. Build the tabs from the game aggKey

Fetch the game’s markets by aggKey and group them client-side by marketGroup: GET /venue-markets?aggKey={gameAggKey}&status=open&limit=100&cursor=…
  • aggKey is game-scoped: you get every market of the game across companion events + venues (moneyline, spreads, totals, props), deduped to one row per logical market (cross-venue siblings on matchedVenueMarkets[]).
  • The visible tab set is the marketGroups present in this result — not the groups of the opened event. Hide groups with no markets. Paginate large games with cursor/limit.
  • To load tabs lazily instead of all at once, add a marketGroup (+ period) filter per tab — but the visible tab set still comes from the game, so probe which groups exist up front (one unfiltered aggKey page is usually enough; a game is dozens of markets, not hundreds).
  • Other enums for sub-grouping/ordering within a tab: sportsMarketType (moneyline/spread/total/prop/to_advance/completion/other), period (see the full vocabulary below — full/1h/2h/q1..q4/set_N/map_N/inning_N/…), lineValue (number), sectionRank (order).

Game Lines moneyline — source it from the game root

The aggKey set carries moneyline rows from every sibling venue-event, so the raw set can show duplicate “Draw”s or drop an outcome. For the match-result N-way, take the moneyline markets from the opened game event instead: GET /venue-markets?venueEventId={openedEventId}&marketGroup=game_lines Keep the spread/total ladders from the full aggKey set. Listing endpoints already surface group roots (?grouped=true), so the event you open IS the root in the normal flow.
?venueEventId= is cluster-scoped by default (cluster=true): every venue’s markets for that event, matched siblings folded into one anchor row. That fold is what removes the duplicate outcomes, so no client-side dedupe. Send cluster=false for one venue event’s own markets, and pass the same value to GET /venue-events/{id}, whose marketCount follows the flag — details.The row you get back for a matched cluster is its anchor — the other venues’ rows arrive on matchedVenueMarkets[]. Matching prefers a member carrying the game aggKey as the anchor, but does not guarantee one: an anchor’s aggKey may still be null, so keep handling that case.It does not reach same-venue companions, which never share a cluster. The step 2 aggKey query remains the only game-wide fetch.

Spread / total ladders — group by the aggKey line-family, NOT the question

A spread or total is one card with a line selector (O/U 0.5 / 1.5 / 2.5 …), and the same line is offered by several venues. Do not group these by question or marketSubtype — each venue phrases and labels them differently for the same market (e.g. "Argentina O/U 0.5" / "Will over 0.5 goals be scored?" / null), which fragments one ladder into separate cards. Group by the line-family key derived from the market aggKey, gated on sportsMarketType:
Both rules drop the period token — take it from the period field and put it in your group key alongside the family. Within a family, union the venues per line. Order totals by lineValue; order spreads by |lineValue|, taking the side of the axis from the m/p token, since polymarket stores spreads negative and kalshi/novig/predict store them positive.
Both failure modes here are silent — a wrong grouping, not an error. A bare _\d+p\d+ strip no longer matches spread keys (they read _m19p5), and splitting on a marker instead of the date can find a team of that name. Full detail: Marker tokens are not unique.
Total subjects. A match total (…_tot_8p5), a team total (…_tot_team_arg_1p5), corners (…_tot_corners_9p5), cards (…_tot_cards_3p5) and — for racket sports — total sets (…_tot_sets_3p5) each get their own family. The sets token matters: a tennis match publishes both a total-sets market (2.5–4.5) and a total-games market (36.5–40.5), and without a subject to separate them they land in one card whose selector mixes the two.
venueMarketOutcomes[0] is the team the key NAMES — the favourite on an m row and the UNDERDOG on a p one. Labelling index 0 as “favourite −X” unconditionally mislabels every p row, and the mislabel is silent: the pill reads as the side that covers the spread while the book underneath is the side receiving the points. Read the role off the leg token, never off the outcome order.

When a market has no aggKey — absent does not mean different

aggKey is a pricing key. Every venue mints it fail-closed: if the derivation cannot prove two markets are the same bet, it emits nothing rather than risk a wrong merge. A meaningful share of open spread/total markets therefore carry aggKey: null, and a great many props carry none by design. So aggKey answers “can I route money between these?” — not “are these one card?” Treating a missing key as “a different market” is what produces the classic detail-page failure: Spreads twice, Totals twice, one card per venue per line. Give the no-key path a structural family (period + marketGroup + sportsMarketType + normalized marketSubtype) rather than falling back to the market id.

Decomposed N-way markets — one card, one pill per outcome

A venue that cannot list a three-way natively publishes one bare Yes/No market per outcome, with the subject in the question and usually no aggKey:
Render these as one card with a pill per leg, not one card per leg. The legs of one question share the venue’s marketSubtype, and the token reconciles across venues once you strip a namespace prefix — polymarket’s soccer_halftime_result and predict’s SPORTS_HALFTIME_RESULT are the same question. Restrict the rule to per-question groups (game_lines, period_lines, team_props, specials, completion): player_props members are per-PLAYER, so a dozen markets sharing baseball_player_hits are a dozen different bets, not a dozen legs.
A ladder rung is not a leg. Every rung of a line ladder is also its own Yes/No market with its own question ("Boston over 1.5 runs scored", "…over 2.5…"), which is shape-identical to a decomposed leg. The difference is what the markets vary along: a leg is an outcome of one question, a rung is a line of one market. lineValue is that flag — if any market in the group has one, render a ladder with a line selector, not a pill per market.
question on a decomposed leg is the venue’s own short label, not a stable identity. /venue-markets returns polymarket’s leg as Real Betis Balompié and predict’s identically-worded leg as BET. Comparing prose across venues will not fold them; only matchedVenueMarkets[] proves two legs are the same bet.

A sanity check for any card: the pill invariant

A card that draws one pill per market can never honestly show more pills than the most legs a single venue publishes for that question — a question has no more outcomes than the venue that enumerates all of them. Any excess is the same bet appearing twice under two venues’ wordings. When you hit it and cannot fold the duplicates, split the card by venue: each venue does enumerate its own question completely, so one card per venue keeps every pill accurate and hides nothing. Two exceptions are not violations — a line ladder (the rail renders the rungs, not the pills), and a group anchored by a market whose outcomes are already team-titled (it states the whole question on its own, and the other markets are its decomposition).

Tab → filter mapping

  • Game LinesmarketGroup ∈ {game_lines, spreads, totals} (moneyline from the root member, above) plus sportsMarketType=to_advance (see “Team to Advance” below — it carries marketGroup=specials but belongs here).
  • HalvesmarketGroup=period_lines, period ∈ {1h, 2h} (basketball / american_football also expose quarters q1q4, hockey period_1period_3 — same period_lines group, sub-grouped by period). Do NOT also filter on sportsMarketType: half-result legs (“France leading at halftime?”) are sportsMarketType=other and an AND’d type filter drops them.
  • Exact ScoremarketGroup=exact_score
  • PropsmarketGroup ∈ {team_props, player_props, specials, completion}, excluding sportsMarketType=to_advance (those render under Game Lines).
  • OthermarketGroup=other
Exact-score and both-teams-to-score markets now carry their own aggKey (…_cs_{teamA}{scoreA}_{teamB}{scoreB} and …_btts), so those tabs get cross-venue identity from the key and matchedVenueMarkets[] the same way Game Lines does. Player props and individual strikes of a scalar ladder still return aggKey: null — group those by marketSubtype and rely on the cluster for linkage. See the aggKey coverage table.

Team to Advance — a Game Lines market, above the moneyline

Knockout games (World Cup, playoff series, etc.) carry a “Team to Advance” market: sportsMarketType=to_advance. It is a primary game line — venues render it above the moneyline — but its marketGroup is specials, so filter it in by sportsMarketType, not marketGroup: source to_advance from the game set alongside the moneyline and place it first in the Game Lines tab. For a venue whose only market on a game is the advance single (it lists nothing else), this is what makes the Game Lines tab non-empty. Some venues decompose it into per-team Yes/No legs (…_adv_{team}) while others list one 2-outcome single (…_adv, team-name pills). Group the whole family with the line-family key (below); anchor the card on the bare-_adv single when present.

Prop tabs via marketCategory (deprecated — use marketGroup instead)

marketCategory is deprecated in favor of marketGroup (see the Fields reference below). It remains populated for back-compat — the soccer-only prop tabs (corners, goals, assists, shots) still appear there — but new code should filter by marketGroup.
Each market carries a normalized marketCategory — the subject grouping for the prop tabs, orthogonal to sportsMarketType:
  • game_lines — moneyline / spreads / totals (sub-group with sportsMarketType + period)
  • exact_score, corners, goals, assists, shots — the dedicated prop tabs (soccer only)
  • other — recognized sports markets without a dedicated tab; null for non-sports markets
Load one prop tab the same way you load Game Lines: GET /venue-markets?aggKey={gameAggKey}&marketCategory=corners&limit=50 As with every other enum, the API exposes the category; your UI owns the tab label.

3. Prices, order ticket, chart

  • Live cross-venue prices / best price: GET /orderbook/midpoints?venueMarketIds=… (the market list carries no prices; midpoints are the price source).
  • Order ticket / smart routing (split across venues): GET /orderbook/{venueMarketOutcomeId}/route?maxSpend=…&compareVenues=true → per-venue fills[] + venueSoloQuotes[].
  • Price chart: GET /charts/bars?venueMarketOutcomeId=…&resolution=5.

Fields reference

The following fields are available on /venue-events and /venue-markets for sports content.

sport (on VenueEvent)

Normalized cross-venue sport classification. null for non-sports events. Closed vocabulary:

groupParentId (on VenueEvent)

The id of this event’s same-venue group base, or null when the event is the base (or is standalone). It is the explicit signal for “the event I opened is a companion, not the game” — the title suffix (… - Exact Score, … : Spread) is a hint, not a contract. Two consequences worth designing around:
  • grouped=true returns only rows where this is null. That is the definition of one tile per game.
  • A companion’s matchedVenueEvents[] is not the game’s cluster. Same-venue events never share a cluster, so a companion matches the other venues’ equivalent companions. On a companion, venueCount can read 1 with an empty matchedVenueEvents[] while the game itself is live on four venues. Resolve the game by aggKey, or fetch groupParentId for the base. See Companion events.
Cross-venue pricing is unaffected either way: each market carries its own matchedVenueMarkets[] regardless of how its parent events cluster.

marketGroup (on VenueMarket)

Sport-aware FE grouping. Use this to build per-sport tab sets. Supersedes marketCategory for sports tabs (see note above). The marketGroup query parameter on /venue-markets accepts any value from this set. Closed vocabulary:

marketSubtype (on VenueMarket)

Lossless raw venue market type string preserved from discovery. Examples: cricket_toss_winner, nrfi, tennis_set_winner, cs2_odd_even_total_kills, baseball_team_first_five_winner, soccer_halftime_result, map_handicap. Use marketSubtype to label or disambiguate rows within a marketGroup (e.g. render the nrfi row with its proper label rather than a generic “Team Props” heading). null when the venue supplies no subtype.

period (on VenueMarket)

Temporal scope of the market. Expanded from the original set to cover all sports:

Per-sport tab matrix

Use this table as the starting point for building per-sport tab UIs. Render only the marketGroup values that appear for a given sport; hide empty tabs.
Coverage reflects what venues currently list. other and other_sport are catch-alls. The set is venue-driven and grows as new market types are listed.
sport is resolved from the venue’s league code first, then its tags (which carry a clean sport name like “Rugby” or “Volleyball”), then the title. Sports a venue lists but that aren’t yet in the vocabulary fall to other_sport (still classified into marketGroups) until added.

Filter examples

Baseball — team props for a specific game

Returns the team-prop markets for that game: NRFI (nrfi, period=inning_1), first-5-innings winner (baseball_team_first_five_winner, period=first_5_innings), and any other team-level propositions available across venues. Each row’s matchedVenueMarkets[] contains the cross-venue pairings so you can show the best price without a second request.

Tennis — set and match totals

Returns all over/under totals for the match: per-set totals (period=set_1, period=set_2, …) and full-match totals (period=match). Use marketSubtype to distinguish the rendering: tennis_first_set_totals gets a “First Set” label; tennis_match_totals gets “Match Total”.