JSON Odds Feed: Format, Schema & Parsing in Python (Free API)
You made one call to /v4/odds and got back a wall of nested JSON. Five levels deep, integer keys everywhere, prices in three different formats, and an exchangeMeta object that’s null on some books and a full order-book ladder on others. Where exactly is the price? What’s safe to assume? This is the field-by-field map of the OddsPapi JSON odds feed — the exact schema, the gotchas, and copy-paste Python that parses it without guessing.
Every payload, field name, and number below was pulled live from the API on a real MLB fixture (Cubs vs Giants, 14 bookmakers) on the day this was written. Nothing here is invented.
Why “just parse the JSON” goes wrong
Most odds feeds fail you in one of two ways. Either they’re flat and undocumented — a generic API hands you {"home": 1.95, "away": 2.05} for 20 soft books and you have no idea what market or what timestamp that even is — or they’re deeply nested with no schema docs, so you end up writing brittle code that breaks the first time a key you assumed was always present comes back null.
OddsPapi’s feed is the second kind: deeply nested, but consistent. Once you know the shape, parsing 350+ bookmakers across every sport is the same five-line walk every time. The nesting exists for a reason — one fixture can carry hundreds of markets, each with multiple outcomes, each with a current price and (for exchanges) a depth-of-book ladder. The structure is the price you pay for that much data in one response. This guide is the map.
| The naive assumption | What the feed actually does |
|---|---|
Top-level key is odds or data |
It’s bookmakerOdds on the live endpoint, bookmakers on historical |
| Markets/outcomes are arrays | They’re dicts keyed by stringified integer IDs |
| The price is on the outcome | It’s one more level down, under players["0"] |
players["0"] is always a dict |
Dict on /odds (one current price), list on /historical-odds (full history) |
exchangeMeta is always {} |
null on sportsbooks; a back/lay ladder on exchanges |
| Every outcome has a live price | Some are active: false or ship price: 0 — filter them |
Step 1: Authenticate and pull a payload
The API key is a query parameter, never a header. Every call needs it.
import requests
API_KEY = "YOUR_API_KEY" # get a free key at oddspapi.io
BASE = "https://api.oddspapi.io/v4"
def api(path, **params):
params["apiKey"] = API_KEY
return requests.get(f"{BASE}/{path}", params=params).json()
# One fixture, all books that price it:
odds = api("odds", fixtureId="id1300010963302659")
print(list(odds.keys()))
# ['fixtureId', 'participant1Id', 'participant2Id', 'sportId', 'tournamentId',
# 'seasonId', 'statusId', 'hasOdds', 'startTime', 'trueStartTime',
# 'trueEndTime', 'updatedAt', 'bookmakerOdds']
Two things to know before you go further:
- Only fixtures with
hasOdds: true(from/fixtures) return abookmakerOddspayload. Everything else returns fixture metadata only — or, occasionally,{"error": ...}. Always check the key exists before indexing it. - You can narrow the response with
bookmakers="pinnacle,bet365,polymarket"(comma list). Omit it to get every book that prices the fixture.
Step 2: The envelope → bookmaker → market → outcome → price
Here’s the full path, top to bottom. Memorize this one diagram and the rest is mechanical:
bookmakerOdds # dict, keyed by bookmaker slug ("pinnacle", "bet365")
→ [slug]
bookmakerIsActive # bool
bookmakerFixtureId # the book's own ID for this fixture
fixturePath # deep link path on the book
suspended # bool — book-level suspension
markets # dict, keyed by stringified marketId ("131")
→ [market_id_str]
bookmakerMarketId
marketActive
outcomes # dict, keyed by stringified outcomeId ("131")
→ [outcome_id_str]
players # dict, keyed by "0" (and player IDs for props)
→ ["0"] # a DICT on /odds — the current price object
Note what is not in this payload: human-readable market and outcome names. The feed gives you integer IDs (131, 1010) to keep responses small. You resolve names from the /markets catalog — covered in Step 5.
The canonical way to reach a single current price:
slug = "pinnacle"
market_id = "131" # Moneyline / Winner (stringified)
outcome_id = "131" # Home / "1"
price = (odds["bookmakerOdds"][slug]["markets"][market_id]
["outcomes"][outcome_id]["players"]["0"]["price"])
Step 3: The outcome object, field by field
This is the leaf of the tree — players["0"] on the live /odds endpoint — and it’s where most people under-read the data. A real Pinnacle outcome, captured live:
{
"active": true, # is this price live? FILTER on this
"betslip": null, # deep-link to the book's betslip (exchanges/US books often set it)
"bookmakerOutcomeId": "-1.0/away", # the book's own outcome handle
"bookmakerChangedAt": "2026-06-05T14:42:07.980Z", # when the BOOK last moved it (can be null)
"changedAt": "2026-06-05T14:42:08.558Z", # when OddsPapi last saw a change
"limit": 3546.0, # max stake the book will accept (null if unknown)
"playerName": null, # set on player-prop outcomes
"price": 1.793, # DECIMAL odds — the number you want
"priceAmerican": "-126", # same price, American format (string)
"priceFractional": "23/29", # same price, fractional format (string)
"mainLine": false, # is this the book's headline line for the market?
"exchangeMeta": null # null on sportsbooks; ladder object on exchanges (Step 6)
}
Three fields people miss and shouldn’t:
priceAmerican/priceFractional— the feed pre-converts every price into all three formats. You never have to write a decimal↔American converter; just read the field. They ship as strings.changedAt— the freshness timestamp. This is what powers steam-move detectors: compare a sharp book’schangedAtagainst a soft book’s to flag a stale line.limit— the book’s max accepted stake. Sharp books (Pinnacle) expose real numbers; most soft books sendnull.
A note for anyone migrating older code: sportsbook exchangeMeta is now null, not the empty {} you may have seen before. Treat “falsy” (null or {}) as “no exchange data” and you’re covered both ways.
Step 4: Defensive parsing — the four rules
Never assume a price is usable just because the outcome exists. These four guards prevent ~every parsing bug we’ve hit across 350+ books:
def usable_price(outcome):
# Return a clean decimal price, or None if this outcome isn't tradeable.
p = outcome.get("players", {}).get("0")
if not isinstance(p, dict): # rule 1: /odds is a dict; /historical-odds is a list
return None
if not p.get("active"): # rule 2: skip suspended / inactive prices
return None
price = p.get("price")
if not price or price <= 0: # rule 3: some books ship price=0 with active=true
return None
return price
def book_prices(odds, slug, market_id):
# All usable outcome prices for one book + market, keyed by outcomeId.
book = odds.get("bookmakerOdds", {}).get(slug)
if not book or book.get("suspended"): # rule 4: respect book-level suspension
return {}
market = book.get("markets", {}).get(str(market_id))
if not market:
return {}
out = {}
for oid, outcome in market["outcomes"].items():
price = usable_price(outcome)
if price:
out[oid] = price
return out
print(book_prices(odds, "pinnacle", 131))
# {'131': 1.598, '132': 2.53} # Home / Away moneyline
The single most common mistake is mixing the live and historical shapes: players["0"] is a dict (one price) on /odds but a list of snapshots on /historical-odds. Rule 1 above catches it explicitly. More on historical in Step 7.
Step 5: Turning integer IDs into names
The odds feed is all integers. Human-readable names live in the /markets catalog, one call per sport. Build two lookup dicts and you're done:
catalog = api("markets", sportId=13) # 13 = baseball
# Each entry: {marketId, marketName, handicap, period, marketType, playerProp, outcomes}
market_names = {m["marketId"]: m["marketName"] for m in catalog}
outcome_names = {
(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in catalog for o in m.get("outcomes", [])
}
print(market_names[131]) # 'Winner (incl. extra innings)'
print(outcome_names[(131, 131)],
outcome_names[(131, 132)]) # '1' '2' (home / away)
Don't hardcode the whole catalog — soccer alone has 32,814 market IDs once you count every handicap and total line variant. Look up the few you display, cache the dict, and move on. The same marketName ("Asian Handicap", "Over Under") repeats with different handicap and period values, each carrying its own marketId — which is exactly why the feed keys on IDs, not names.
Step 6: exchangeMeta — the order-book ladder
This is the field that breaks naive parsers. On a sportsbook it's null. On an exchange — Polymarket, Kalshi, Betfair Exchange — it carries the depth-of-book ladder. Here's a real Polymarket outcome's exchangeMeta:
"exchangeMeta": {
"back": [
{"cents": 0.72, "price": 1.389, "size": 315.0, "limit": 226.8},
{"cents": 0.73, "price": 1.37, "size": 631.0, "limit": 460.63},
{"cents": 0.74, "price": 1.351, "size": 1520.13,"limit": 1124.9}
],
"lay": [
{"cents": 0.29, "price": 3.448, "size": 500.0, "limit": 145.0},
{"cents": 0.30, "price": 3.333, "size": 599.0, "limit": 179.7},
{"cents": 0.31, "price": 3.226, "size": 948.13, "limit": 293.92}
],
"bookmakerLayOutcomeId": "84316830488204559480713842070874685117114632260770762655793842995494422951947"
}
What you're looking at:
backis a list of price levels you can bet at, best first — the top entry'spriceequals the outcome's mainprice.layis the opposite side (laying / selling).- Each level has
price(decimal odds),size(liquidity available), andcents(the native 0–1 share price — relevant on prediction markets like Polymarket and Kalshi). bookmakerLayOutcomeIdis the opposite side's token/handle (Polymarket ships it; Kalshi doesn't). Drop it straight into the venue's order-book endpoint if you need raw depth.
The key parsing lesson: read the top of the back ladder, not a flat scalar. Older exchange payloads used different shapes — a flat lay number, or availableToBack/availableToLay on Betfair. Parse defensively:
def best_back(outcome):
p = outcome["players"]["0"]
meta = p.get("exchangeMeta")
if not meta: # sportsbook: null or {}
return p.get("price")
back = meta.get("back")
if isinstance(back, list) and back:
return back[0].get("price") # best back = top of ladder
if isinstance(back, (int, float)): # legacy flat shape
return back
return p.get("price") # fall back to the main price
For the full Polymarket / Kalshi structure — condition IDs, token IDs, and the Gamma-vs-CLOB split — see the Polymarket API deep dive.
Step 7: The historical feed has a different shape
The /historical-odds endpoint looks similar but differs in two ways that will bite you if you reuse your live parser verbatim:
- The top-level key is
bookmakers, notbookmakerOdds. players["0"]is a list of snapshots (full price history), not a single dict.
hist = api("historical-odds", fixtureId="id1300010963302659",
bookmakers="pinnacle,bet365,draftkings") # max 3 books per call
snaps = (hist["bookmakers"]["pinnacle"]["markets"]["131"]
["outcomes"]["131"]["players"]["0"]) # a LIST
print(len(snaps), "snapshots") # 47 snapshots
print(snaps[0])
# {'createdAt': '2026-06-05T09:57:12.731Z', 'price': 1.613,
# 'limit': 3058, 'active': true, 'exchangeMeta': null}
for s in snaps:
print(s["createdAt"], s["price"]) # full line-movement history
Historical data is capped at 3 bookmakers per call — loop with different combos and merge if you need more. And unlike most providers who paywall it, OddsPapi's historical feed is on the free tier, which makes it the cheapest way to backtest a model or compute closing-line value.
Putting it together: parse, name, compare
A complete, runnable example — pull the moneyline across every book, label it, de-vig the sharp line, and find the best available price. All numbers are the live Cubs vs Giants pull:
odds = api("odds", fixtureId="id1300010963302659")
names = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in api("markets", sportId=13) for o in m.get("outcomes", [])}
MONEYLINE = 131
board = {} # slug -> {outcomeId: price}
for slug in odds.get("bookmakerOdds", {}):
prices = book_prices(odds, slug, MONEYLINE)
if len(prices) == 2: # both sides priced
board[slug] = prices
# Best price per side across the whole board
for oid in ("131", "132"):
best = max(board.items(), key=lambda kv: kv[1][oid])
label = names.get((MONEYLINE, int(oid)), oid)
print(f"Best '{label}': {best[1][oid]} @ {best[0]}")
# Best '1': 1.613 @ kalshi
# Best '2': 2.564 @ kalshi
# De-vig Pinnacle for a fair-probability benchmark
h, a = board["pinnacle"]["131"], board["pinnacle"]["132"]
ih, ia = 1/h, 1/a
overround = ih + ia
print(f"Pinnacle vig: {(overround-1)*100:.2f}%") # 2.10%
print(f"Fair: home {ih/overround*100:.1f}% / away {ia/overround*100:.1f}%")
# Fair: home 61.3% / away 38.7%
What the live data showed: 10 of the 14 books priced the moneyline. US sportsbooks (DraftKings, FanDuel, BetMGM, Caesars) ran 4.0–4.7% vig, Pinnacle 2.10%, and the prediction markets — Kalshi and Polymarket — landed at 1.00%, the tightest in the field and right on Pinnacle's no-vig fair line. Those are the best available prices, captured at one moment; early lines tighten by game time, so treat them as a snapshot, not a standing edge. For a reusable scanner, see line shopping in Python.
Where to go from here
You now have the whole map: the envelope, the five-level path, the outcome fields, the exchange ladder, and the historical shape. The same parser works across 350+ bookmakers and every sport — from a single MLB moneyline to an Asian Handicap board to a Polymarket order book. If you'd rather not poll at all, the WebSocket feed pushes the same JSON shape in real time, and the odds feed overview covers the product side. To go straight from this JSON into analysis, the pandas tutorial flattens it into a DataFrame in ten lines.
Stop reverse-engineering undocumented feeds. Get your free API key and parse a real one.
Frequently Asked Questions
Where is the actual price in the OddsPapi odds JSON?
Five levels deep: bookmakerOdds[slug]["markets"][marketId]["outcomes"][outcomeId]["players"]["0"]["price"]. The market and outcome IDs are stringified integers, and on the live /odds endpoint players["0"] is a dict holding one current price.
Why are markets and outcomes dicts instead of arrays?
They're keyed by integer ID (as strings) so you can look up a specific market or outcome in O(1) without scanning a list. A single fixture can carry hundreds of markets, so ID-keyed dicts keep both the payload and your lookups fast.
What's the difference between the live and historical JSON?
The live /odds feed uses the top-level key bookmakerOdds and players["0"] is a single dict. The /historical-odds feed uses the key bookmakers and players["0"] is a list of price snapshots over time. Historical is capped at 3 bookmakers per call and is free.
What is the exchangeMeta field?
On sportsbooks it's null. On exchanges (Polymarket, Kalshi, Betfair Exchange) it carries the order-book depth as back and lay ladders — lists of price levels with price, size, and native cents share prices. Read the top of the back ladder for the best price.
How do I get human-readable market and outcome names?
The odds feed only ships integer IDs. Call /markets?sportId=X once, build {marketId: marketName} and {(marketId, outcomeId): outcomeName} lookup dicts, and cache them. Don't hardcode the catalog — soccer alone has over 32,000 market IDs.
Do I need to convert decimal odds to American myself?
No. Every outcome ships price (decimal), priceAmerican, and priceFractional together, so you just read the format you want. The American and fractional fields are strings.