College Football Odds API: NCAAF Lines, Spreads and Totals

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

You point /odds at a Week 1 college football game, filter on active == True the way every parsing tutorial tells you to, and get back almost nothing. Fifteen bookmakers are on the fixture. Your dict is empty.

The odds are there. On the eight NCAA Week 1 fixtures we pulled on August 5, 2026, bookmakers had posted 1,649 prices and flagged 958 of them active: false. That is 58% of the college board sitting in the payload as suspended quotes. Filter them out and the season looks like it does not exist. Leave them in and you are reading numbers nobody will take a bet on.

This guide covers the college football side of the OddsPapi feed: finding the NCAA tournament inside sportId=14, parsing spreads and totals where every line carries its own market ID, and reading the active flag so your scanner sees the board the way a trader does.

Why college football breaks odds parsers

The pro leagues train you into bad habits. An NFL Sunday game has sixteen books quoting a stable moneyline, one spread everyone agrees on, and a prop tree that fills in reliably. College football has 136 FBS programs, a schedule that runs from a Week 0 game in Dublin to December bowl season, and a book-by-book opening pattern that looks nothing like the NFL.

Three things make it awkward:

  • Books open in waves. Pinnacle appeared on three of our eight Week 1 fixtures and quoted a moneyline on two of them. SBOBet, Circa, Bet365 and ten others appeared on all eight. Caesars posted a 109-market alt-line ladder on UNLV v Memphis and left 190 of its 218 outcomes suspended.
  • Sport 14 is not the NFL. The same sportId carries NFL, NFL Preseason, CFL, NCAA, AFLE and EFA. Filter on tournamentId or you will mix Canadian football into your college model.
  • Every line is a different market ID. There is no generic “spread” endpoint. Total 49.5 is market 1484, total 49 is 1482, spread -6.5 is 14260. Hardcode one and you will read empty dicts all season.

Scraping ESPN or a book’s own site gives you one source, no history, and a rewrite every time the front end changes. The public sports APIs that do carry college football mostly ship a handful of US retail books, charge for historical data, and skip the sharps entirely.

Task Scraping / generic API OddsPapi
Books per college fixture 1 site, or ~8 US retail 13 to 16, including Pinnacle, SBOBet, Circa, Kalshi
Suspended vs live prices No signal, or silently dropped active flag on every outcome
Alt spreads and totals Main line only Full ladder, 112 distinct market IDs on one game
Line history Paid add-on Free tier, snapshots from the day the book opened
Stake limits Not published limit on Pinnacle and exchange outcomes

Step 1: Find the college football tournament

Authentication is a query parameter. No headers, no OAuth dance.

import requests, time

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
SPORT_ID = 14  # American Football

r = requests.get(f"{BASE_URL}/tournaments",
                 params={"apiKey": API_KEY, "sportId": SPORT_ID})

for t in r.json():
    if (t.get("futureFixtures") or 0) > 0:
        print(f"{t['tournamentId']:>6}  {t['tournamentName']:<22} "
              f"{t.get('categoryName')}  future={t['futureFixtures']}")

Live output on August 5, 2026:

    31  NFL                    USA            future=118
   233  NFL Preseason          USA            future=49
   790  CFL                    Canada         future=47
 27653  NCAA, Regular Season   USA            future=3507
 51578  EFA                    International  future=3
 51580  AFLE                   International  future=12

College football is tournament 27653, slug ncaa-regular-season, and it carries 3,507 scheduled fixtures. That number dwarfs the NFL’s 118 because it covers every FBS and FCS program on the calendar. Only a fraction of them carry odds at any moment, which is the next problem to solve.

Step 2: Pull fixtures and check hasOdds

The /fixtures endpoint takes a date range up to 10 days wide. Each fixture carries a hasOdds boolean. Skip the ones where it is false, because /odds will return metadata and no prices.

NCAA = 27653

r = requests.get(f"{BASE_URL}/fixtures",
                 params={"apiKey": API_KEY, "sportId": SPORT_ID,
                         "from": "2026-08-22", "to": "2026-08-31"})

games = [f for f in r.json()
         if f["tournamentId"] == NCAA and f["hasOdds"]]

print(f"{len(games)} college fixtures with odds")
for g in games[:3]:
    print(" ", g["fixtureId"], g["startTime"][:16],
          g["participant1Name"], "v", g["participant2Name"])
8 college fixtures with odds
  id1402765370894628 2026-08-29T16:00 TCU Horned Frogs v North Carolina Tar Heels
  id1402765370898438 2026-08-29T19:00 USC Trojans v San Jose State Spartans
  id1402765370894634 2026-08-29T19:30 Virginia Cavaliers v NC State Wolfpack

Eight fixtures out of 175 scheduled in that window. Three and a half weeks before kickoff, the books have opened the marquee games and left the rest closed. Run the same query in mid-September and the count climbs into the dozens.

Step 3: Parse the odds and count what is live

Here is the shape of a /odds response. The top-level key is bookmakerOdds, and the path to a price runs four levels deep:

bookmakerOdds[slug]["markets"][market_id]["outcomes"][outcome_id]["players"]["0"]

That leaf dict holds price (decimal), priceAmerican, priceFractional, limit, mainLine, and the flag this whole post turns on:

FIXTURE = "id1402765370894628"  # TCU v North Carolina

r = requests.get(f"{BASE_URL}/odds",
                 params={"apiKey": API_KEY, "fixtureId": FIXTURE})
books = r.json().get("bookmakerOdds", {})

live = suspended = 0
for book in books.values():
    for market in book["markets"].values():
        for outcome in market["outcomes"].values():
            for entry in outcome["players"].values():
                if entry["active"]:
                    live += 1
                else:
                    suspended += 1

print(f"{len(books)} bookmakers on the fixture")
print(f"{live} live prices, {suspended} suspended "
      f"({100 * live / (live + suspended):.1f}% live)")
16 bookmakers on the fixture
130 live prices, 52 suspended (71.4% live)

TCU v North Carolina is one of the healthier boards. Run the same count across all eight fixtures and the picture changes.

The active flag census: 58% of the college board is suspended

We ran the loop above over every NCAA Week 1 fixture carrying odds on August 5, 2026. Per book, across all eight games:

Bookmaker Live prices Suspended % live
pinnacle 90 0 100.0%
kalshi 8 0 100.0%
betmgm 34 6 85.0%
borgata 32 6 84.2%
hardrockbet 50 14 78.1%
fourwinds 40 12 76.9%
ballybet / betparx / betrivers 26 each 10 each 72.2%
sbobet 56 24 70.0%
circasports 28 12 70.0%
bet365 66 34 66.0%
draftkings 46 24 65.7%
pointsbet.com.au 27 28 49.1%
caesars 68 384 15.0%
williamhill 68 384 15.0%
All books 691 958 41.9%

Two patterns fall out of that table.

Pinnacle and Kalshi never post a dead price. Both hit 100% live. When Pinnacle has an opinion it will take a bet on it, and when it does not, the market is absent from the payload rather than present and suspended. That makes Pinnacle’s presence a coverage signal on college football in a way it is not on the NFL.

Caesars and William Hill inflate the market count. On UNLV v Memphis, Caesars posted 109 markets: 61 total lines from 44.5 to 74.5, and 44 spreads from -20.5 to +20.5. Of its 218 outcomes, 28 were live. William Hill returned byte-identical numbers, which tracks: /v4/bookmakers flags the pair as clones, and they quote the same feed. If you count markets to rank coverage, these two will top your table on a board you cannot bet into.

The fix is one line in your parser. Count books that quote both sides live, not books that appear in the payload:

def live_lines(books, market_id):
    """{slug: (price_a, price_b)} for books quoting BOTH sides live."""
    out = {}
    for slug, book in books.items():
        market = book["markets"].get(str(market_id))
        if not market:
            continue
        ids = sorted(int(o) for o in market["outcomes"])
        if len(ids) != 2:
            continue
        a, b = (market["outcomes"][str(i)]["players"]["0"] for i in ids)
        if a["active"] and b["active"]:
            out[slug] = (a["price"], b["price"])
    return out

Applied to the TCU moneyline, that gives you 15 books. Applied to the spread on the same fixture, it gives you 4. Both numbers are correct, and only one of them is safe to build a consensus on.

Step 4: Resolve market IDs instead of hardcoding them

College football spreads and totals follow the NFL convention: one market ID per line. Total 48.5 is 1480, total 49 is 1482, total 49.5 is 1484. Spread -6.5 is 14260, spread -7 is 14258. There is no stable “the totals market” ID to paste into your code.

Worse, /v4/markets ignores the sportId parameter. Query it with sportId=10 or sportId=14 and you get the identical 32,815-row global catalogue both times, cricket innings markets included. Use it as a name lookup, never as a discovery tool for what a sport supports.

The reliable move is to read the market IDs off a live payload and count how many books quote each one:

from collections import Counter

catalog = requests.get(f"{BASE_URL}/markets",
                       params={"apiKey": API_KEY, "sportId": SPORT_ID}).json()
market_info = {m["marketId"]: m for m in catalog}

counts = Counter()
for book in books.values():
    for mid in book["markets"]:
        counts[int(mid)] += 1

for mid, n in counts.most_common(6):
    info = market_info.get(mid, {})
    print(f"  {n:>2} books  id={mid:<7} {info.get('marketName')}  "
          f"handicap={info.get('handicap')}")
  15 books  id=141     Winner (incl. overtime)  handicap=0
  12 books  id=1484    Total (incl. overtime)  handicap=49.5
  10 books  id=14260   Handicap (incl. overtime)  handicap=-6.5
   8 books  id=1480    Total (incl. overtime)  handicap=48.5
   8 books  id=1482    Total (incl. overtime)  handicap=49
   6 books  id=1486    Total (incl. overtime)  handicap=50

The consensus line is whichever handicap the most books quote. Here that is 49.5 on the total and -6.5 on the spread. Six books also quote 50 and eight quote 49, so a scanner that assumes one number per game will miss most of the ladder.

Do not lean on the mainLine flag to pick for you. On this fixture every book that flagged the -6.5 spread mainLine: true had also suspended it, while the four books taking bets on that line carried mainLine: false.

Step 5: De-vig Pinnacle, then shop the board

Pinnacle’s price is the sharp reference. Strip its margin to get a fair probability, then check what the rest of the board is paying.

ml = live_lines(books, 141)

home, away = ml["pinnacle"]
ih, ia = 1 / home, 1 / away
total = ih + ia

print(f"Pinnacle {home}/{away}, margin {(total - 1) * 100:.2f}%")
print(f"  fair home {ih / total * 100:.1f}%  ({total / ih:.3f})")
print(f"  fair away {ia / total * 100:.1f}%  ({total / ia:.3f})")

best_home = max(ml.items(), key=lambda kv: kv[1][0])
best_away = max(ml.items(), key=lambda kv: kv[1][1])
print(f"  best home {best_home[1][0]} @ {best_home[0]}")
print(f"  best away {best_away[1][1]} @ {best_away[0]}")
Pinnacle 1.347/3.3, margin 4.54%
  fair home 71.0%  (1.408)
  fair away 29.0%  (3.450)
  best home 1.408 @ kalshi
  best away 3.448 @ kalshi

Look at those four numbers. Pinnacle’s de-vigged fair line is 1.408 / 3.450. Kalshi is quoting 1.408 / 3.448. The prediction market has landed on the sharp book’s no-vig number to three decimal places, and its own margin comes out at 0.03%.

That is worth understanding rather than trading on. Kalshi carries a real cost structure and its stake limits on this game were $1,872 on TCU and $114 on North Carolina, so the dog side is a $114 market, not an edge. What you get for free is a de-vigged reference price you did not have to compute, on a fixture where Pinnacle may not have opened at all.

Here is the full moneyline board, sorted by margin:

Bookmaker TCU North Carolina Margin
kalshi 1.408 3.448 0.03%
bet365 1.357 3.300 3.99%
fourwinds 1.360 3.250 4.30%
draftkings 1.351 3.300 4.32%
betmgm / borgata 1.350 3.300 4.38%
pinnacle 1.347 3.300 4.54%
caesars / williamhill 1.345 3.300 4.65%
ballybet / betparx 1.380 3.100 4.72%
betrivers 1.380 3.050 5.25%
hardrockbet 1.333 3.250 5.79%
sbobet 1.350 3.040 6.97%
pointsbet.com.au 1.300 3.200 8.17%

Fifteen slugs collapse to twelve distinct price tuples. Caesars and William Hill are identical, BetMGM and Borgata are identical, BallyBet and BetParx are identical. Dedupe on the price tuple before you average anything or your “consensus” quietly triple-weights one trading desk. For the same reason, treat a book count as a count of independent opinions, not of logos.

One more thing the margin column shows: Pinnacle is not the tightest book on this game. Six books priced the college moneyline inside Pinnacle’s 4.54%. That inverts what you see on MLB or the Premier League, and it is a symptom of an early-season market rather than a soft-book mistake. The next section explains why.

Reading the limits: how to tell a real market from a placeholder

Pinnacle publishes a limit on every outcome, and the limit encodes a maximum win rather than a maximum stake. Back out the base with limit * (price - 1) for a favourite and you get the figure the trading desk stands behind.

On TCU v North Carolina, Pinnacle’s limit was 720 on a 1.347 favourite. That works out to a base of 250. On Virginia v NC State, 543 at 1.460 gives the same 250. For comparison, Pinnacle was running bases of 1,875 to 7,500 on same-day MLB games this summer, and a Premier League opener two months out sat at 250 before doubling to 500 as the season approached.

A base of 250 means the sharpest book on the planet will let you win $250 on this game. Three weeks out, on a college fixture, that is a placeholder line. It explains the wide margin, and it is the single most useful signal in the payload for deciding whether a number is worth modelling against.

Free historical odds let you watch the number wake up:

r = requests.get(f"{BASE_URL}/historical-odds",
                 params={"apiKey": API_KEY, "fixtureId": FIXTURE,
                         "bookmakers": "pinnacle"})

# NOTE: on /historical-odds the top key is "bookmakers" (not "bookmakerOdds")
# and players["0"] is a LIST of snapshots, not a single dict.
snaps = (r.json()["bookmakers"]["pinnacle"]["markets"]["141"]
          ["outcomes"]["141"]["players"]["0"])

for s in snaps:
    print(s["createdAt"][:19], s["price"], "limit", s["limit"])
2026-07-30T10:14:30 1.344 limit 726
...
2026-08-04T16:29:20 1.347 limit 720

Pinnacle opened this game on July 30, a full month before kickoff, and moved it once in six days. Against a Premier League fixture that repriced thousands of times over a comparable window, the college board is asleep. Poll it daily rather than every thirty seconds, and spend your request budget on the games that are moving.

How the board fills in: the NFL preseason control

To check whether the suspended-price pattern is a college quirk or a time-to-kickoff effect, we ran the same census on NFL Preseason fixtures the same afternoon:

Fixture Days out Books Prices % live
Arizona v Carolina 2 16 906 66.0%
Cincinnati v Detroit 8 2 4 100.0%
Houston v LA Chargers 9 2 4 100.0%
NCAA Week 1 (8 fixtures) 24 13 to 16 1,649 41.9%

The two books quoting those distant preseason games are Kalshi and Polymarket. No sportsbook had opened them at all. Prediction markets price first, sportsbooks arrive later with a deep menu that is mostly suspended, and the menu goes live as kickoff approaches. If you are building a college football scanner, that ordering tells you where to point it in August versus October.

What college football does not have

Three honest gaps, so you do not build against something that is not there.

No player props. Across all eight Week 1 fixtures and 112 distinct market IDs, we found zero player markets. No passing yards, no anytime touchdown, nothing keyed by player. The NFL catalogue carries Player To Score TD (14388) and First TD (14390), and US retail books do price them, but college prop menus had not opened. Re-probe in September before promising a prop feed.

No outrights. The American football catalogue has no futures markets beyond a coin toss, so national championship, conference and Heisman odds are out of reach. /fixtures is strictly two-participant.

No scores. The feed carries schedules, status and odds. If you need results to grade a model, join out via the externalProviders block on each fixture, which ships Betradar, OpticOdds and Pinnacle IDs.

A working college football scanner

Everything above, wired into one loop over the slate. It respects the per-endpoint rate limit with a one-second gap and skips anything without a live two-sided market.

import requests, time
from collections import Counter

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

def live_lines(books, market_id):
    out = {}
    for slug, book in books.items():
        market = book["markets"].get(str(market_id))
        if not market:
            continue
        ids = sorted(int(o) for o in market["outcomes"])
        if len(ids) != 2:
            continue
        a, b = (market["outcomes"][str(i)]["players"]["0"] for i in ids)
        if a["active"] and b["active"]:
            out[slug] = (a["price"], b["price"])
    return out

def devig(price_a, price_b):
    ia, ib = 1 / price_a, 1 / price_b
    total = ia + ib
    return ia / total, ib / total, total - 1

fixtures = requests.get(f"{BASE_URL}/fixtures", params={
    "apiKey": API_KEY, "sportId": 14,
    "from": "2026-08-22", "to": "2026-08-31"}).json()

for f in [x for x in fixtures if x["tournamentId"] == NCAA and x["hasOdds"]]:
    time.sleep(1.0)   # per-endpoint cooldown, honour retryMs on a 429
    resp = requests.get(f"{BASE_URL}/odds", params={
        "apiKey": API_KEY, "fixtureId": f["fixtureId"]})
    if resp.status_code != 200:      # a 429 body is valid JSON, check this first
        print("rate limited, backing off"); time.sleep(2); continue

    books = resp.json().get("bookmakerOdds", {})
    ml = live_lines(books, 141)
    if len(ml) < 3:
        continue

    name = f"{f['participant1Name']} v {f['participant2Name']}"
    best_a = max(ml.items(), key=lambda kv: kv[1][0])
    best_b = max(ml.items(), key=lambda kv: kv[1][1])

    line = f"{name:<52} {len(ml):>2} books"
    if "pinnacle" in ml:
        pa, pb, margin = devig(*ml["pinnacle"])
        line += f"  | sharp {pa:.1%}/{pb:.1%} (vig {margin:.2%})"
    else:
        line += "  | no Pinnacle line"
    print(line)
    print(f"    best: {best_a[1][0]} @ {best_a[0]}  /  "
          f"{best_b[1][1]} @ {best_b[0]}")

Swap 141 for the handicap or total ID the most books quote and the same loop shops spreads and totals. Point it at tournamentId 31 and it covers the NFL without another line changing.

Where to go next

The parsing patterns here carry across the rest of the feed. For the pro game, the NFL Odds API guide covers the same endpoints with a fuller prop tree, and NFL key numbers puts a price on the half point once you have spreads flowing. To turn the board into a best-price table across every book, see line shopping in Python. The limit field gets a full treatment in betting limits and stake sizing, and if you want the three-way comparison of de-vig methods used above, read no-vig odds. New to the API, start with the free odds API overview.

FAQ

Is there a free college football odds API?

Yes. OddsPapi’s free tier covers NCAA football through the same /v4/odds endpoint as every other sport, including Pinnacle, SBOBet, Circa, Kalshi and the US retail books, plus historical price snapshots at no cost. Authentication is an apiKey query parameter.

Why does my college football odds request return no prices?

Two likely causes. The fixture’s hasOdds field is false, in which case /odds returns metadata only. Or your parser filters on active == True and the books have posted their lines suspended, which accounted for 58% of quoted prices on the Week 1 board we sampled in August 2026.

What is the market ID for a college football spread?

Each line has its own ID. Spread -6.5 is 14260, spread -7 is 14258, total 49.5 is 1484, total 49 is 1482. Read the IDs off a live /odds payload, count how many books quote each, and use the one with the widest coverage rather than hardcoding a value.

Does the API cover college football player props?

Not on the Week 1 board sampled in August 2026. All 112 market IDs across eight fixtures were game lines: moneyline, spreads, totals, team totals and odd/even. NFL player props are in the catalogue as markets 14388 and 14390, so re-probe college fixtures closer to the season.

Can I get college football futures or national championship odds?

No. The American football catalogue carries no outright markets beyond a coin toss, and /fixtures only returns two-participant events. Championship, conference and award odds are out of scope.

How often should I poll college football odds?

Daily is enough three weeks out. Pinnacle opened TCU v North Carolina on July 30 and changed the price once in the following six days. Tighten the cadence in game week, and use the limit field to tell which games the sharp books have started taking real money on.

Get the feed

Week 0 kicks off on August 22. The board is thin now and it will not stay that way, so wire up the parser while the games are cheap to poll. A free key gives you every college fixture in the catalogue, 349 bookmakers across 69 sports, and historical snapshots from the day each book opened its line.

Grab a free API key and stop guessing which prices are real.