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.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).
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=…
aggKeyis 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 onmatchedVenueMarkets[]).- 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 withcursor/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 unfilteredaggKeypage 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:
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.
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.
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 noaggKey:
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.
question on a decomposed leg is the venue’s own short label, not a stable identity.
/venue-markets returns polymarket’s leg as Real Betis Balompié and predict’s identically-worded
leg as BET. Comparing prose across venues will not fold them; only matchedVenueMarkets[] proves
two legs are the same bet.A sanity check for any card: the pill invariant
A card that draws one pill per market can never honestly show more pills than the most legs a single venue publishes for that question — a question has no more outcomes than the venue that enumerates all of them. Any excess is the same bet appearing twice under two venues’ wordings. When you hit it and cannot fold the duplicates, split the card by venue: each venue does enumerate its own question completely, so one card per venue keeps every pill accurate and hides nothing. Two exceptions are not violations — a line ladder (the rail renders the rungs, not the pills), and a group anchored by a market whose outcomes are already team-titled (it states the whole question on its own, and the other markets are its decomposition).Tab → filter mapping
- Game Lines —
marketGroup ∈ {game_lines, spreads, totals}(moneyline from the root member, above) plussportsMarketType=to_advance(see “Team to Advance” below — it carriesmarketGroup=specialsbut belongs here). - Halves —
marketGroup=period_lines,period ∈ {1h, 2h}(basketball / american_football also expose quartersq1–q4, hockeyperiod_1–period_3— sameperiod_linesgroup, sub-grouped byperiod). Do NOT also filter onsportsMarketType: half-result legs (“France leading at halftime?”) aresportsMarketType=otherand an AND’d type filter drops them. - Exact Score —
marketGroup=exact_score - Props —
marketGroup ∈ {team_props, player_props, specials, completion}, excludingsportsMarketType=to_advance(those render under Game Lines). - Other —
marketGroup=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)
Each market carries a normalized marketCategory — the subject grouping for the prop tabs,
orthogonal to sportsMarketType:
game_lines— moneyline / spreads / totals (sub-group withsportsMarketType+period)exact_score,corners,goals,assists,shots— the dedicated prop tabs (soccer only)other— recognized sports markets without a dedicated tab;nullfor non-sports markets
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-venuefills[]+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=truereturns only rows where this isnull. 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,venueCountcan read1with an emptymatchedVenueEvents[]while the game itself is live on four venues. Resolve the game byaggKey, or fetchgroupParentIdfor the base. See Companion events.
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 themarketGroup 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
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
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”.