College Football Player Props API: 22 Markets Across 63 Books
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
- College football odds coverage — which of the 136 Week 1 games have a board at all, and how to tell before you call.
- College football odds API — lines, spreads and totals for NCAAF.
- Player props API for NFL, NBA and MLB — the same parsing pattern on the pro leagues.
- Player props value scanner — finding outliers once you have the board.
- NFL alternate lines API — why ladder markets suspend the way they do.
- MLB player props API — home runs, strikeouts and hits.
- Vig calculator in Python — the margin maths for two-sided props.
- Consensus odds — what to do after you dedupe.
- Line shopping in Python — best price per player across the board.
- Free odds API — the tier all of this runs on.
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.