College Football Odds API: One Field Finds Every Priced Board

College Football Odds - OddsPapi API Blog
How To Guides August 29, 2026

College football Week 1 puts 136 NCAA games on the board between Thursday and Monday, 67 of them on Saturday alone. The ones the market prices are deep: a median of 107 bookmakers per fixture, topping out at 145, with full spread, total and player-prop menus behind them.

The catch is that those 136 fixtures include the Division II and III slate: Barton at Virginia State, Wingate at North Carolina-Pembroke, Kentucky Wesleyan at Kentucky Christian. No sportsbook prices those games, so 58 of the 136 come back with an empty board. That is the market, not the feed. If no book has opened a line, there is no line to serve.

So the job is to tell the two groups apart before you spend the calls. One field in the /fixtures response does it, and it cuts 136 /odds calls down to 54 with no loss. This post shows the filter, then what a 145-book college board actually contains. All measured live, on the free tier.

The distribution is not a bell curve, it is a cliff

Books per fixture across all 136 Week 1 games:

Bookmakers on the fixture Fixtures Share
0 58 42.6%
1 to 9 24 17.6%
10 to 49 0 0%
50 to 99 5 3.7%
100+ 49 36.0%

Nothing sits between 10 and 49 books. A college football fixture is either fully covered or barely covered, and the median of 2.0 books per fixture describes no actual game on the slate. If you are reporting a coverage average for this sport, you are reporting a number that does not exist.

The empty half is the small-college slate: Barton at Virginia State, Wingate at North Carolina-Pembroke, Marian at Indianapolis, Kentucky Wesleyan at Kentucky Christian. Real fixtures with real kick-off times that no sportsbook prices.

The one-call fix: read externalProviders

Every fixture from /fixtures carries an externalProviders object mapping it to third-party data vendors. On college football, the number of keys in it predicts the board almost perfectly.

Providers on the fixture Fixtures Median books Empty boards
1 (betradarId only) 82 0 58 (and the rest max out at 2)
3 9 106 0
4 44 107 0
5 1 141 0

All 54 fixtures with two or more providers have a real board. Not one exception. Every fixture carrying only betradarId tops out at two bookmakers. So one filter on the fixtures response cuts 136 /odds calls down to 54, keeps 100% of the real boards, and costs you nothing but a handful of two-book fixtures.

import requests, time

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, {}

# NCAA regular season is tournamentId 27653 under sportId 14.
# `to` is a midnight-UTC instant: use the day AFTER your last day.
status, fixtures = get("fixtures", sportId=14, tournamentId=27653,
                       **{"from": "2026-08-27", "to": "2026-09-02"})

def likely_covered(f, min_providers=2):
    providers = {k: v for k, v in (f.get("externalProviders") or {}).items() if v}
    return len(providers) >= min_providers

worth_calling = [f for f in fixtures if likely_covered(f)]
print(len(fixtures), "fixtures ->", len(worth_calling), "worth an /odds call")
# 136 fixtures -> 54 worth an /odds call

Note the null-check on the values. The field is always present; what varies is how many of its keys are populated.

The caveat that keeps this honest

This works because externalProviders is sparse on college football. It is useless on a sport where the field is full. On a Grand Slam tennis draw, all ten provider IDs populate on every row, including bracket placeholders with no players in them yet, so the field carries no signal at all.

Check the shape before you trust it. One line tells you which regime you are in:

import collections

spread = collections.Counter(
    len({k: v for k, v in (f.get("externalProviders") or {}).items() if v})
    for f in fixtures
)
print(sorted(spread.items()))
# [(1, 82), (3, 9), (4, 44), (5, 1)]  -> sparse, so it predicts
# [(10, 253)]                         -> saturated, so it does not

pinnacleId is a near-perfect predictor of the sharp line

The same field carries a second, sharper signal. pinnacleId appears on 8 of the 136 fixtures. Pinnacle actually priced 6 of those 8, and 0 of the other 128.

Circa Sports tracks it exactly: the same six fixtures, no others. So if you need a sharp benchmark for a college game, pinnacleId tells you whether one can exist before you spend the call. Six fixtures out of 136 is the honest size of the sharp college board in Week 1.

sharp_candidates = [f for f in fixtures
                    if (f.get("externalProviders") or {}).get("pinnacleId")]
print(len(sharp_candidates), "fixtures where a Pinnacle line is even possible")   # 8

Prediction markets go where sportsbooks will not

Those 24 fixtures with exactly two bookmakers are the interesting tail. The two books are the same every time: kalshi and polymarket.us. No sportsbook at all.

Across the whole 136-game slate, presence runs:

Venue Fixtures priced
kalshi 73 of 136
bet365 52
draftkings 48
pinnacle 6
circasports 6
polymarket 0

Kalshi covers more college football than any sportsbook on the board, and it is the only venue quoting Division II football at all. Note the split between polymarket and polymarket.us: the international slug is on none of the 136 and the US slug is on the long tail. They are different feeds, so query the one you mean.

What a covered game actually looks like

The deepest board of the weekend is North Carolina at TCU: 145 bookmakers, 1,778 distinct market IDs. Two filters before you count anything.

First, drop the internal test feeds. pinnacle+30 and pinnacle+5 quote the identical number to pinnacle, so an unfiltered count includes the sharp three times.

Second, dedupe on the price tuple. Several distinct slugs ship byte-identical prices while the catalogue reports cloneOf: null. Across all 136 fixtures, 5,191 complete quotes collapse to 1,939 independent prices, a 62.6% collapse. On the TCU game, 129 usable quotes become 58.

def moneylines(fixture_id):
    """{slug: (home_price, away_price)} for active, two-sided quotes only."""
    status, odds = get("odds", fixtureId=fixture_id)
    out = {}
    for slug, book in odds.get("bookmakerOdds", {}).items():
        if slug.startswith("pinnacle+") or slug == "demo":
            continue
        market = book.get("markets", {}).get("141")     # Winner (incl. overtime)
        if not market:
            continue
        legs = {}
        for outcome_id, outcome in market["outcomes"].items():
            price = outcome["players"].get("0")          # game lines key on "0"
            if price and price.get("active") and price.get("price"):
                legs[outcome_id] = price["price"]
        if len(legs) == 2:
            out[slug] = (legs["141"], legs["142"])
    return out

quotes = moneylines("id1402765370894628")   # North Carolina @ TCU
print(len(quotes), "quotes ->", len(set(quotes.values())), "independent")
# 129 quotes -> 58 independent   (live counts drift between calls)

Margin ranking on that fixture, 129 ranked books:

Rank Book Margin
1 polymarket.us 0.52%
2 novig.us 0.98%
3 kalshi 1.02%
4 prophetx 1.22%
9 circasports 3.60%
17 pinnacle 4.19%
129 sesamesport.bg 11.29%

Sixteen books beat Pinnacle on price here, and most of them are exchanges or prediction markets. That ranking says nothing about how much money sits behind each quote — polymarket.us in particular posts the tightest number on the board and frequently has nothing behind it, so read the exchangeMeta ladder before treating a tight quote as a benchmark.

Two-thirds of the board is switched off

Across the 136 fixtures, 471,588 prices and only 31.3% active. On the TCU game specifically it is 35.6%.

Books load a full menu early and suspend most of it. The flag that matters lives on the price object at players["0"], one level below the outcome. Do not use marketActive, suspended or bookmakerIsActive — all three disagree with their own prices often enough to break a parser.

This is the same effect as the board-width rule elsewhere: a wider board contains proportionally fewer live prices. Filter first, count second.

Old way vs OddsPapi

Scraping / generic sports APIs OddsPapi
Week 1 slate Schedule from one source, odds from another One call, 136 fixtures, one ID space
Knowing which games have odds Try them all externalProviders filter, 136 → 54
Board depth One scraper per book Up to 145 books in one JSON call
Sharp benchmark Pinnacle account required pinnacle and circasports in the payload
Vendor cross-mapping Buy a mapping product Betradar, Genius, LSports, OpticOdds IDs included

A working Saturday pull

  1. Pull the week from /fixtures with tournamentId=27653. Remember an empty window returns HTTP 404, not an empty array.
  2. Keep fixtures with two or more populated externalProviders keys. That is your real slate.
  3. Flag the pinnacleId subset separately — those are the only games where a sharp line can exist.
  4. Call /odds on the survivors with time.sleep(1.0) between calls. Do not parallelise; at any worker count almost everything comes back 429.
  5. Drop pinnacle+ and demo slugs, filter on price-level active, then dedupe on the price tuple before you average anything.

Where to go next

Spend your calls where the board is

College football is the widest schedule in American sport and the most uneven board in it. The difference between 136 calls and 54 is one filter on a field you already have, and the difference between 145 books and 57 real prices is one dedupe. Both are free, on 350+ bookmakers including Pinnacle, Circa, bet365, DraftKings and Kalshi, with free historical odds behind every fixture.

Get your free API key and filter this Saturday’s slate before kick-off.