Response Shapes
Game-line odds, props, futures, prediction markets, and exchange markets each return a slightly different JSON shape. They diverged because they grew at different times in different parts of the system. This page is the explicit map: what each looks like, how they differ, when each was introduced, and how to handle the differences in client code.
Why three shapes (instead of one)
Game-line odds inherits the the-odds-api.com shape because we ship as a drop-in. Player props grew on top of that with extra fields for player and market. Futures came later for season-long markets and uses a flatter shape since there are no recurring "events". Prediction markets and exchanges add a few more fields to expose volume and bid/ask. We won't break v1 to merge these; the answer is to document the differences carefully and offer normalization helpers in the SDK.
Game-line odds (h2h, spreads, totals)
Endpoint: GET /v1/sports/{sport_key}/odds. Shape returned:
[
{
"id": "abc123",
"sport_key": "baseball_mlb",
"sport_title": "MLB",
"commence_time": "2026-05-01T19:00:00Z",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"bookmakers": [
{
"key": "draftkings",
"title": "DraftKings",
"last_update": "2026-05-01T18:56:55Z",
"markets": [
{
"key": "h2h",
"last_update": "2026-05-01T18:56:55Z",
"outcomes": [
{ "name": "Boston Red Sox", "price": -126 },
{ "name": "Houston Astros", "price": +108 }
]
},
{
"key": "spreads",
"last_update": "2026-05-01T18:56:55Z",
"outcomes": [
{ "name": "Boston Red Sox", "price": -110, "point": -1.5 },
{ "name": "Houston Astros", "price": -110, "point": +1.5 }
]
},
{
"key": "totals",
"last_update": "2026-05-01T18:56:55Z",
"outcomes": [
{ "name": "Over", "price": -110, "point": 8.5 },
{ "name": "Under", "price": -110, "point": 8.5 }
]
}
]
}
]
}
]
| Field | Notes |
|---|---|
id | Stable per (date, home, away). Use as a join key with /props and /scores. |
commence_time | ISO 8601, UTC, kickoff time. Pre-game window is everything before this. |
bookmakers[].last_update | Per-book freshness. We capture this directly from each book's response. Treat as upper bound on price age. |
markets[].last_update | Per-market freshness within a book. Usually equals bookmakers[].last_update; can lag if the book updates h2h faster than spreads. |
outcomes[].price | American odds by default. Use ?oddsFormat=decimal to flip. |
outcomes[].point | Spread number for spreads, total for totals. Absent on h2h. |
Player props
Endpoint: GET /v1/sports/{sport_key}/props. Different shape: instead of nesting markets inside bookmakers inside events, we flatten one row per (event, book, market, player, line) tuple. This makes filter-by-player and filter-by-market dramatically easier in clients.
[
{
"event_id": "abc123",
"sport_key": "baseball_mlb",
"commence_time": "2026-05-01T19:00:00Z",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"source": "draftkings",
"source_title": "DraftKings",
"player_name": "Aaron Judge",
"market_key": "batter_home_runs",
"market_label": "Home Runs",
"line": 0.5,
"over_price": +295,
"under_price": -380,
"over_implied_prob": 0.2532,
"under_implied_prob": 0.7917,
"snapshot_time": "2026-05-01T18:55:30Z"
}
]
event_id in your code.
Implied probability fields (over_implied_prob, under_implied_prob) are derived from the raw American prices via the standard formula. We include them because every client computes them anyway. They're not different data from over_price / under_price; they're a convenience.
Futures
Endpoint: GET /v1/sports/{sport_key}/futures. Season-long markets (championship, MVP, totals). No recurring events, so we flatten further:
[
{
"sport_key": "basketball_nba",
"season": "2025-26",
"market_key": "nba_championship_winner",
"market_label": "NBA Championship Winner",
"selection": "Boston Celtics",
"source": "draftkings",
"source_title": "DraftKings",
"price": +450,
"implied_prob": 0.1818,
"snapshot_time": "2026-05-01T18:55:30Z"
}
]
One row per (season, market, selection, book). No nested arrays. commence_time is absent because futures don't have a single kickoff. line is absent because there's no over/under in most championship markets (use price).
Prediction markets
Endpoint: GET /v1/prediction-markets/{sport_key}. Kalshi and Polymarket prices normalized to American/decimal odds plus market-specific fields:
[
{
"event_id": "kalshi-MLBYANK-NEW-2026-05-01",
"sport_key": "baseball_mlb",
"commence_time": "2026-05-01T19:00:00Z",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"source": "kalshi",
"selection": "Boston Red Sox",
"yes_price": +112,
"no_price": -130,
"yes_implied_prob": 0.4717,
"no_implied_prob": 0.5652,
"volume_24h_usd": 4287.50
}
]
Same general shape as game-line odds, with two additions:
yes_price/no_priceinstead of nestedbookmakers[].markets[].outcomes[]. Prediction-market contracts are binary; we expose them flat.volume_24h_usd: dollar volume traded on the contract in the last 24 hours. Absent for sportsbook responses since books don't disclose volume.
Prediction markets (crypto: BTC live, ETH archive)
Endpoint: GET /v1/prediction-markets/crypto/{asset}. Same source (Kalshi) as the sports-event prediction-market endpoint above but stored in its own table with crypto-specific fields because the contracts behave differently (24/7 trading, no team, no kickoff). The collector polls every open Kalshi market for the assets it covers every ~5 s and stores every snapshot; the live endpoint returns the latest per market.
What is actually there, measured 2026-08-01. Live (the last hour): BTC only, market types price_at_or_above and price_range. Archive (from 2026-05-13): BTC on those two, plus ETH on price_at_or_above and other, ETH having stopped writing on 2026-07-30. sol, doge and xrp are accepted asset names but no rows have ever been collected for them.
Both endpoints refuse a combination they cannot answer with 404 NO_CRYPTO_COVERAGE and no credit charged, rather than billing you for an empty array. The two use different tests, on purpose: the live endpoint refuses anything outside the hour it queries and its message points you at the historical endpoint, which refuses only what has never been collected at all. So /v1/prediction-markets/crypto/eth is a 404 today while /v1/historical/prediction-markets/crypto/eth serves the full archive.
{
"asset": "BTC",
"market_type": "all",
"count": 184,
"as_of": 1778664156530,
"markets": [
{
"timestamp_ms": 1778664156530,
"source": "kalshi",
"series_ticker": "KXBTCD",
"market_ticker": "KXBTCD-26MAY1306-T89799.99",
"event_ticker": "KXBTCD-26MAY1306",
"asset": "BTC",
"market_type": "price_at_or_above",
"market_title": "Bitcoin price on May 13, 2026?",
"close_time": "2026-05-13T10:00:00+00:00",
"yes_bid_cents": 12,
"yes_ask_cents": 14,
"no_bid_cents": 86,
"no_ask_cents": 88,
"yes_implied_prob": 0.13,
"volume": 14820,
"open_interest": 3210,
"last_price_cents": 13
}
]
}
Optional filter: ?market_type= one of:
direction_15m: 15-minute binary "will BTC be higher in 15 minutes?" (when Kalshi has them listed)direction_1h: hourly direction binariesdirection_daily: daily direction binariesprice_at_or_above: price-ladder strikes ($50 increments, hourly windows)price_range: "between $X and $Y" range marketsall(default), every market we track for the asset
Historical replay at GET /v1/historical/prediction-markets/crypto/{asset}?from=&to=&market_ticker=. Every poll snapshot is preserved (5 s cadence per market), so a one-hour replay against a single market_ticker returns ~720 rows. Cost: 1 credit live, 2 credits historical.
Exchange markets (Novig)
Endpoint: GET /v1/exchange/{sport_key}/markets. Exchanges expose bid/ask + matched volume, which sportsbooks don't:
[
{
"event_id": "abc123",
"sport_key": "baseball_mlb",
"commence_time": "2026-05-01T19:00:00Z",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"source": "novig",
"market_key": "h2h",
"selection": "Boston Red Sox",
"best_bid": -130,
"best_ask": -118,
"last_traded": -124,
"volume_usd": 1245.00,
"is_consensus": true
}
]
Raw vs normalized
Response fields are normalized: we extract them from each source, type-coerce them, and present them in a consistent shape so the same field means the same thing across books. Some rows also carry a raw_json field holding source-supplied context we did not promote to a column of its own, such as an upstream identifier or the commence time exactly as the source stated it. It is not a copy of the upstream response body, its contents differ per source, and it is absent from most endpoints, so treat it as a hint rather than a record.
The game-line endpoint GET /v1/sports/{sport_key}/odds accepts shape tokens:
?include=normalized # (default) every field we build for the event
?include=slim # drops raw_json from any row that carries one
?include=raw # identifying fields + the bookmakers tree + raw_json,
# dropping derived fields (canonical_event_id,
# sport_title, probable_pitchers, starting_lineups)
As of the 2026-08-30 check, /odds events do not carry a raw_json field, so on that endpoint slim returns the same fields as normalized, and raw differs from normalized only by the derived fields it drops. Use raw when you want the smallest event envelope that still carries every book and price.
Shape tokens are specific to /odds. /props serves a single shape and rejects include= with a 400 unless the value is normalized, so a request that asks for a shape it cannot serve fails instead of being quietly answered with the standard body.
Per-book timestamps
Three places to find freshness, in increasing specificity:
- Top-level response header
X-Data-As-Of: when this query was served from cache (or freshly computed if uncached). - Per-row
snapshot_time(props, futures, prediction markets) orbookmakers[].last_update(game-line odds): when we captured this specific row from the source. - Per-market
markets[].last_updatewithin a bookmaker: when this specific market within this book last changed.
For backtesting / model training, use the per-row timestamp. For "is this fresh enough to bet on", use the per-market timestamp.
Side-by-side: same game, three shapes
game-line odds
{
"id": "abc123",
"home_team": "...",
"away_team": "...",
"commence_time": "...",
"bookmakers": [
{
"key": "draftkings",
"markets": [
{
"key": "h2h",
"outcomes": [
{ "name": "...", "price": -126 }
]
}
]
}
]
}
player props
{
"event_id": "abc123",
"source": "draftkings",
"player_name": "Aaron Judge",
"market_key": "batter_home_runs",
"line": 0.5,
"over_price": +295,
"under_price": -380
}
futures
{
"season": "2025-26",
"market_key": "nba_championship_winner",
"selection": "Boston Celtics",
"source": "draftkings",
"price": +450
}
Schema stability policy
- We add fields without versioning. New optional fields on responses don't break clients.
- We never remove or rename existing fields under
/v1/. Anything we want to remove ships under/v2/as a new namespace. raw_json, where an endpoint carries it, is source-defined and can change at any time. We don't promise its shape, and we don't promise it is present on any given endpoint.- Field semantics are documented here. If we change a semantic (extremely rare), we ship a per-endpoint deprecation header, give 30 days notice, and run both old and new in parallel during the window.
The detailed migration log lives in the changelog.