JSON Odds Feed: Format, Schema & Parsing in Python (Free API)

JSON Odds Feed - OddsPapi API Blog
How To Guides June 16, 2026

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 a bookmakerOdds payload. 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’s changedAt against 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 send null.

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:

  • back is a list of price levels you can bet at, best first — the top entry's price equals the outcome's main price. lay is the opposite side (laying / selling).
  • Each level has price (decimal odds), size (liquidity available), and cents (the native 0–1 share price — relevant on prediction markets like Polymarket and Kalshi).
  • bookmakerLayOutcomeId is 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:

  1. The top-level key is bookmakers, not bookmakerOdds.
  2. 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.