College Football Player Props API: 22 Markets Across 63 Books

College Player Props - OddsPapi API Blog
How To Guides August 30, 2026

Three weeks ago the college football board had zero player props. Not a thin menu — none, across 112 distinct market IDs.

They arrived for Week 1. The current NCAA slate carries 21,627 player-keyed prices across 22 prop families and 63 bookmakers, from receiving yards ladders to first touchdown scorer. This post shows how to pull them, and the two things that will make your parser return an empty dict if you get them wrong.

The trap: props are not keyed like game lines

Every OddsPapi price sits under a players dict. On a moneyline or a spread, that dict has exactly one key, the string "0". Every tutorial you have read hardcodes it.

On a player prop, players is keyed by player ID instead, and one outcome holds the entire roster at once. “Over 49.5 receiving yards” is a single outcome containing every receiver the book prices, each with its own price and a playerName in "Last, First" format.

So outcome["players"]["0"] raises a KeyError or returns nothing on every prop market on the board.

import requests, time, collections

API_KEY  = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

def get(path, **params):
    for _ in range(5):
        params["apiKey"] = API_KEY
        r = requests.get(f"{BASE_URL}/{path}", params=params)
        if r.status_code == 429:
            time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 1.2)
            continue
        return r.status_code, r.json()
    return r.status_code, {}

def player_prices(outcome):
    """Yield every player-level price on an outcome. Skips the game-line key."""
    for player_id, price in outcome["players"].items():
        if player_id == "0":          # "0" == not a player prop
            continue
        yield player_id, price

Step 1: build the market lookup

Market IDs are integers and human-readable names come from /v4/markets. Two things to know before you use it.

sportId on that endpoint is a no-op. The catalogue is global — 32,815 rows — not per sport, so you cannot use it to discover which markets college football supports. Read the live market IDs off the /odds payload and use the catalogue purely as a lookup.

American football uses one market ID per line. The receiving-yards family alone spans 88 distinct market IDs on a single slate, one per yardage threshold. Never hardcode them; resolve by marketName.

status, catalogue = get("markets", sportId=14)

MARKET = {m["marketId"]: (m["marketName"], m.get("handicap")) for m in catalogue}
OUTCOME = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
           for m in catalogue for o in m.get("outcomes", [])}

Step 2: pull a fixture and collect the props

NCAA regular season is tournamentId 27653 under sportId 14. Coverage is extremely uneven — many small-college fixtures return no bookmakers at all — so pick a fixture with a real board before you go looking for props.

def props(fixture_id):
    status, odds = get("odds", fixtureId=fixture_id)
    rows = []
    for slug, book in odds.get("bookmakerOdds", {}).items():
        if slug.startswith("pinnacle+") or slug == "demo":     # internal test feeds
            continue
        for market_id, market in book.get("markets", {}).items():
            family, handicap = MARKET.get(int(market_id), ("?", None))
            for outcome_id, outcome in market["outcomes"].items():
                label = OUTCOME.get((int(market_id), int(outcome_id)))
                for player_id, price in player_prices(outcome):
                    rows.append({
                        "book": slug, "family": family, "handicap": handicap,
                        "label": label, "player_id": player_id,
                        "player": price.get("playerName"),
                        "price": price.get("price"),
                        "active": price.get("active"),
                    })
    return rows

rows = props("id1402765370894628")     # North Carolina @ TCU
print(len(rows), "player-level prices from",
      len({r["book"] for r in rows}), "books")
# 6163 player-level prices from 60 books

That one fixture returns 6,163 player-level prices from 60 bookmakers. Across the four most prop-heavy fixtures of the slate it comes to 16,599 prices from 63 books.

What is actually on the board

Prop family Prices Active Books Players Market IDs
Over Under Player Receiving Yards 7,366 40.2% 55 19 88
Over Under Rush Yards 2,315 30.0% 26 15 78
Player To Score TD 1,565 97.4% 20 103 2
Over Under Pass Yards 1,354 16.0% 51 3 57
Rush Yards (alt ladder) 1,311 98.6% 38 9 1
Player To Score First TD 807 93.1% 23 102 1
Over Under Player Receptions 381 89.2% 34 75 8
Over Under Longest Rush Yards 370 65.9% 10 30 17
Over Under Player TD Passes 170 87.1% 24 12 3
Over Under Player Assists 289 99.3% 1 146 4
Over Under Sacks 185 98.9% 1 146 1

Twenty-two families in total, down a long tail that includes rush attempts, pass completions, interceptions, longest pass completion and kicking points.

The active rate varies 6x between families

This is the finding that matters most for anything automated. Across those 16,599 prices only 54.8% are active, and the split is not random:

  • Touchdown-scorer markets are live. Player To Score TD 97.4%, First TD 93.1%. These are single-market families with one line and no ladder.
  • Yardage ladders are mostly switched off. Pass Yards 16.0%, Rush Yards 30.0%, Receiving Yards 40.2%. These are the families with 57 to 88 market IDs each — a book posts the whole ladder and keeps a handful of rungs open.

So a scanner that skips the active filter sees a receiving-yards market that looks 2.5 times deeper than it is, and one that filters correctly finds most of the ladder is dead. Both are worth knowing before you build an alerting rule on top.

live = [r for r in rows if r["active"]]
by_family = collections.Counter(r["family"] for r in live)
print(len(rows), "prices ->", len(live), "active")

for family, n in by_family.most_common(10):
    total = sum(1 for r in rows if r["family"] == family)
    print(f"{family[:44]:<46} {n:>5} live / {total:>5}  ({100*n/total:.1f}%)")

Note the flag you filter on. active lives on the price object, one level below the outcome. marketActive, suspended and bookmakerIsActive all exist and all disagree with their own prices often enough to break a parser.

Dedupe before you compare anything

The top prop books by volume look like a diverse field and are not:

Book Prop prices
stake 1,450
bet365 774
bet365.bet.ar, .bet.br, .de, .es, .fr, .gr, .it, .nl 774 each
fliff 568
betsson 470

bet365 and its seven regional skins ship byte-identical prop books. Counting them as eight sources inflates any consensus you build by a factor of eight on the biggest prop provider on the board. All of them report cloneOf: null in the catalogue, so the flag will not save you — dedupe on the price tuple, per fixture.

The scale of it is easy to underestimate. On the TCU game’s receiving-yards market, 53 books collapse to 17 independent prop books, a 68% collapse. Two thirds of your apparent sample is one feed wearing several brands.

def independent(rows, family):
    """Collapse books that ship identical prop books for one family."""
    signature = collections.defaultdict(dict)
    for r in rows:
        if r["family"] == family and r["active"]:
            signature[r["book"]][(r["player_id"], r["handicap"], r["label"])] = r["price"]
    seen, keep = set(), []
    for book, prices in signature.items():
        key = tuple(sorted(prices.items()))
        if key not in seen:
            seen.add(key)
            keep.append(book)
    return keep

Reading a yardage ladder

Yardage props ship as threshold ladders rather than a single line. Outcome names are the thresholds themselves — 3+, 60+, 70+, 90+, 110+, 400+ — alongside plain Over and Under for the two-sided variants.

That means two different shapes live under names that look similar. Over Under Player Receiving Yards is a two-sided market with a handicap; Rush Yards is a one-sided threshold ladder on a single market ID. Only the first can be de-vigged, because only the first has two legs.

def two_sided(rows):
    """Group into (book, player, handicap) and keep only real pairs."""
    groups = collections.defaultdict(dict)
    for r in rows:
        if r["active"] and r["handicap"] is not None:
            groups[(r["book"], r["player_id"], r["handicap"])][r["label"]] = r["price"]
    return {k: v for k, v in groups.items()
            if "Over" in v and "Under" in v}

pairs = two_sided([r for r in rows
                   if r["family"] == "Over Under Player Receiving Yards (incl. overtime)"])
for (book, player, line), legs in list(pairs.items())[:5]:
    margin = (1 / legs["Over"] + 1 / legs["Under"] - 1) * 100
    print(f"{book:<16} player {player} @ {line:>6}  margin {margin:5.2f}%")

Two honest limits

Coverage is thin and concentrated. Props exist on the marquee fixtures and nowhere else. Across the whole 136-game NCAA Week 1 slate there are 21,627 player-keyed prices, and roughly three quarters of them sit on four games. On a small-college fixture there is no board at all, let alone props.

Two families come from a single book. Over Under Player Assists and Over Under Sacks each have 146 players priced by exactly one bookmaker. One source is not a market: there is nothing to check the number against, so treat those two as reference data rather than as a price you can screen.

The same caution applies to Pass Yards, where 51 books quote the family but only three players are priced across it — the quarterbacks, and almost every rung suspended.

Old way vs OddsPapi

Scraping sportsbooks OddsPapi
Prop coverage One scraper per book, breaks weekly 63 books with props in one JSON call
Player identity Free-text names to reconcile Stable player IDs plus playerName
Suspended rungs Rendered like live ones Explicit active flag per price
Market naming Different per book One marketName across the board
Cost Proxies and maintenance Free tier

Where to go next

Props exist now. Parse them properly.

College player props went from nothing to 22 families in three weeks, and the two things that break a prop parser — the player-keyed players dict and the suspended half of every yardage ladder — are both one line of code each. The board is 350+ bookmakers including Pinnacle, bet365, DraftKings and Kalshi, with free historical odds behind every fixture.

Get your free API key and pull this weekend’s prop board.