College Basketball Odds API: Mid-Majors Draw 157 Books

College Basketball Odds API
How To Guides October 19, 2026

Men’s college basketball tips off in the first week of November 2026, and the OddsPapi fixture list already holds 466 men’s fixtures for 2 to 8 November. Sportsbooks have not posted lines that far out. If you want a college basketball model ready for opening night, you build it now on last season’s boards.

We replayed one Saturday, 7 February 2026, through /historical-odds. A Missouri Valley fixture, Northern Iowa v Bradley, drew 157 bookmakers. Auburn v Alabama, the biggest fixture of that afternoon, drew 156. Every number below comes from calls made on 9 October 2026.

Why College Basketball Is Hard to Collect

The regular season runs four months. One Saturday in February, 7 February 2026, put 256 fixtures on the men’s tournament feed. Scrape one sportsbook and you get one price per fixture, no history, and no way to tell whether that price was sharp.

A model that needs closing lines from last season needs an archive, or a full season of its own collection. OddsPapi keeps the history on the free tier, so you can pull last February’s closing board today with the same key you use in November.

Job Scraping or a single-book feed OddsPapi
List the season’s fixtures Crawl a schedule site per conference One /fixtures call on tournamentId 648
Prices per fixture One book 124 to 157 books on the four fixtures we walked
Last season’s closing line Gone once the fixture tips /historical-odds on the free tier
A sharp reference price Pinnacle’s own API needs a commercial account pinnacle slug, with the stake limit attached
Grade a first-half total Scrape a box score /v4/scores returns each half as its own key
Live prices in November Poll and get blocked REST polling or the WebSocket feed

How Deep the Board Runs, Conference by Conference

We picked four fixtures from the 7 February slate, one per tier: an SEC rivalry, a Missouri Valley fixture, a CAA fixture and a MEAC fixture. For each one we walked the full bookmaker catalogue through /historical-odds, three slugs per call. A book counts when it held at least one active price before tip-off. The closing price is the last active snapshot before the tip.

Fixture Conference Books priced Books on the moneyline Distinct moneyline prices Median margin Books on the first half
Auburn v Alabama SEC 156 146 44 5.65% 117
Northern Iowa v Bradley Missouri Valley 157 146 46 5.49% 119
NC A&T v Campbell CAA 142 140 40 5.21% 86
Morgan State v Maryland Eastern Shore MEAC 124 122 41 5.26% 86

The mid-major board matched the SEC board book for book. The median moneyline margin stayed between 5.21% and 5.65% on all four. And the 146 moneyline quotes on Auburn v Alabama collapsed to 44 distinct price pairs, a 69.9% collapse, because many slugs resell the same feed. Dedupe on the price pair before you average, or one supplier gets a dozen votes in your consensus. The consensus odds tutorial covers the dedupe step.

The tightest moneylines came from the exchanges and prediction markets. Prophet Exchange (prophetx) ran 1.21% to 2.00% across the four fixtures, and Kalshi (kalshi) ran 0.99% to 2.98%. Pinnacle ranked 14th to 17th on the three larger boards at 3.84% to 3.96%.

The board opens late on small fixtures

Take the first snapshot each book posted on the moneyline and measure it against the tip. The median book opened 44.2 hours before Auburn v Alabama, 24.2 hours before Northern Iowa v Bradley, 13.1 hours before NC A&T v Campbell and 15.1 hours before Morgan State v Maryland Eastern Shore. Kalshi opened all four more than 52 hours out. If your model wants an opening line on a small-conference fixture, schedule the pull for the morning of the fixture, not two days ahead.

Pinnacle Posts the Slate in Two Waves

We then pulled Pinnacle alone on every third men’s fixture from the 7 February slate, 86 fixtures in all. Most of the fixtures without a Pinnacle price were Division II. Pinnacle held a moneyline on 37 fixtures, and two patterns split those 37 into two groups.

Wave Fixtures First moneyline snapshot (UTC) Moneyline base limit Main spread base limit
Full board 28 13:02 to 13:37 1,500 to 3,211 1,590 to 5,353
Small-conference board 9 15:25 to 15:57 250 to 272 525 to 555

The second wave held fixtures from the MEAC, the SWAC, the Southland, the Big South, the ASUN and the NEC. Pinnacle posts those boards more than two hours after the rest. It accepts about a twelfth of the moneyline stake and about a sixth of the spread stake. The base limit is the limit on the shorter-limit leg. Pinnacle scales the other leg up as the price shortens, which is why Quinnipiac’s moneyline carried a 27,522 limit on the favourite and 3,000 on Niagara.

Use the limit as a confidence weight. A model that treats Pinnacle’s close as the truth on every fixture gives the same weight to a 250-limit SWAC line as to a 3,000-limit Big East line. On the small-conference wave, de-vig the deduplicated consensus instead, as the backtesting guide does for thin markets.

Men’s College Basketball Plays Halves

The NBA plays four quarters. Men’s college basketball plays two 20-minute halves. The market catalogue serves both, so the first-half markets carry the NBA label period: "p1+p2", meaning quarters one and two. Market 111020, Over Under First Half 71.5, is one of them.

/v4/scores labels each segment by its own format. On a men’s college fixture it returns p1 and p2, and each key is one half. We checked 30 men’s fixtures from that weekend: p1 plus p2 equals fulltime on all 30. A women’s college fixture plays quarters and returns p1 to p4.

So a grader that reads "p1+p2" off the market and sums those two score keys grades a men’s first-half total on the full regulation score. Mississippi State v Arkansas went 30-43 at the half and 68-88 at the end. The naive grader sees 156 first-half points against a 77.5 line and pays the Over. The correct grader sees 73 and pays the Under. Step 3 below detects the format before it maps a clock to a score key. The NBA second-half post covers the other basketball clock trap, the regulation and overtime twins.

Step 1: List the Season’s Fixtures

Authentication is a query parameter. The key goes on ?apiKey=, never in a header. Men’s Division I is tournamentId 648, NCAA Regular Season, and the women’s equivalent is 21384.

import os, requests, collections

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"

tournaments = requests.get(f"{BASE_URL}/tournaments",
                           params={"apiKey": API_KEY, "sportId": 11}).json()
for t in tournaments:
    if t["tournamentSlug"] in ("ncaa-regular-season", "ncaa-women-regular-season"):
        print(t["tournamentId"], t["tournamentName"], t["futureFixtures"], "future fixtures")

fixtures = requests.get(f"{BASE_URL}/fixtures", params={
    "apiKey": API_KEY, "sportId": 11, "tournamentId": 648,
    "from": "2026-11-02", "to": "2026-11-09",
}).json()

by_day = collections.Counter(f["startTime"][:10] for f in fixtures)
for day, n in sorted(by_day.items()):
    print(day, n, "fixtures")
print(len(fixtures), "men's fixtures in the opening week")
648 NCAA, Regular Season 2604 future fixtures
21384 NCAA Women, Regular Season 2612 future fixtures
2026-11-02 59 fixtures
2026-11-03 129 fixtures
2026-11-04 30 fixtures
2026-11-05 41 fixtures
2026-11-06 63 fixtures
2026-11-07 95 fixtures
2026-11-08 49 fixtures
466 men's fixtures in the opening week

Dates are UTC, so a 9 pm Eastern tip on 3 November lands on the 4th. Set to to the day after the last day you want, because it is a midnight instant. Tournament 648 also carries Division II fixtures, and sportsbooks do not price most of them. Filter on hasOdds once the lines open, and on the HTTP status of /historical-odds for played fixtures: a 404 there means the books you asked for hold no history on that fixture.

Step 2: Pull a Played Fixture’s Closing Board

/historical-odds returns every snapshot a book posted, capped at three slugs per call. Snapshots run past the tip, so keep only those stamped before startTime, and take the last active one as the close.

import os, time, requests
from datetime import datetime

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
FIXTURE = "id1100064862675787"   # Auburn v Alabama, tipped 7 Feb 2026 21:00 UTC
TIP = "2026-02-07T21:00:00.000Z"

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

def get(path, **params):
    while True:
        r = requests.get(f"{BASE_URL}/{path}", params={"apiKey": API_KEY, **params})
        if r.status_code == 429:                      # honour retryMs, then try again
            time.sleep(r.json()["error"].get("retryMs", 5000) / 1000 + 0.5)
            continue
        r.raise_for_status()
        return r.json()

history = get("historical-odds", fixtureId=FIXTURE,
              bookmakers="pinnacle,draftkings,fanduel")   # three slugs is the cap

for slug, book in history["bookmakers"].items():
    winner = book["markets"]["111"]["outcomes"]          # 111 = Winner (incl. overtime)
    legs = {}
    for outcome_id, outcome in winner.items():
        snaps = [s for s in outcome["players"]["0"] if s["createdAt"] < TIP]
        live = [s for s in snaps if s["active"]]
        legs[outcome_id] = (snaps[0]["createdAt"], live[-1]["price"], live[-1]["limit"])
    first = min(v[0] for v in legs.values())
    hours = (ts(TIP) - ts(first)).total_seconds() / 3600
    home, away = legs["111"][1], legs["112"][1]
    margin = (1 / home + 1 / away - 1) * 100
    limit = legs["111"][2]
    print(f"{slug:11} opened {hours:5.1f} h before tip   close {home} / {away}"
          f"   margin {margin:.2f}%   limit {limit}")
draftkings  opened  44.3 h before tip   close 1.7 / 2.2   margin 4.28%   limit None
fanduel     opened  44.2 h before tip   close 1.649 / 2.28   margin 4.50%   limit None
pinnacle    opened   7.7 h before tip   close 1.689 / 2.24   margin 3.85%   limit 4354.0

DraftKings and FanDuel posted the moneyline 44 hours out. Pinnacle posted it at 13:15 UTC on the day. Only Pinnacle and the exchanges fill limit; sportsbooks return None, so null-check before you do arithmetic on it. To read the rest of the board, loop the call over the catalogue three slugs at a time. The college football odds API post covers the NCAAF side of the same season.

Step 3: Grade the First Half on the Right Score Key

This grader reads the format off /scores, maps each market clock to the score keys it settles on, finds Pinnacle’s main first-half and full-game totals, and grades both.

import os, time, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
FIXTURE = "id1100064862675793"   # Mississippi State v Arkansas, 7 Feb 2026

def get(path, **params):
    while True:
        r = requests.get(f"{BASE_URL}/{path}", params={"apiKey": API_KEY, **params})
        if r.status_code == 429:
            time.sleep(r.json()["error"].get("retryMs", 5000) / 1000 + 0.5)
            continue
        r.raise_for_status()
        return r.json()

# 1. Which score keys make up each market clock? Read the format off /scores.
periods = get("scores", fixtureId=FIXTURE)["scores"]["periods"]
segments = sorted(k for k in periods if k.startswith("p"))
halves = segments == ["p1", "p2"]          # men's college: two 20-minute halves
SCORE_KEYS = {
    "p1+p2": ["p1"] if halves else ["p1", "p2"],   # the first half
    "result": ["result"],                           # final score, overtime included
}
print("score keys:", segments, "-> halves" if halves else "-> quarters")

def points(clock):
    return sum(periods[k]["participant1Score"] + periods[k]["participant2Score"]
               for k in SCORE_KEYS[clock])

# 2. Pull Pinnacle's closing first-half and full-game totals.
markets = {str(m["marketId"]): m for m in get("markets")}
fixture = get("historical-odds", fixtureId=FIXTURE, bookmakers="pinnacle")
tip = get("fixtures", sportId=11, tournamentId=648,
          **{"from": "2026-02-07", "to": "2026-02-08"})
tip = next(f["startTime"] for f in tip if f["fixtureId"] == FIXTURE)

def close(snaps):
    live = [s for s in snaps if s["active"] and s["createdAt"] < tip]
    return live[-1]["price"] if live else None

best = {}
for mid, m in fixture["bookmakers"]["pinnacle"]["markets"].items():
    meta = markets[mid]
    if not meta["marketName"].startswith("Over Under") or "Team" in meta["marketName"]:
        continue
    if meta["period"] not in SCORE_KEYS:
        continue
    prices = {}
    for o in meta["outcomes"]:
        snaps = m["outcomes"].get(str(o["outcomeId"]), {}).get("players", {}).get("0", [])
        prices[o["outcomeName"]] = close(snaps)
    if None in prices.values():
        continue
    gap = abs(prices["Over"] - prices["Under"])
    if meta["period"] not in best or gap < best[meta["period"]][0]:
        best[meta["period"]] = (gap, meta["handicap"], prices)

# 3. Grade each main line on the score keys its clock maps to.
for clock, (_, line, prices) in best.items():
    total = points(clock)
    side = "Over" if total > line else "Under"
    print(f"{clock:7} line {line:6}  close O {prices['Over']} / U {prices['Under']}"
          f"  scored {total:3}  -> {side} wins")

# The trap: read "p1+p2" as two score keys on a halves fixture.
naive = sum(periods[k]["participant1Score"] + periods[k]["participant2Score"] for k in ("p1", "p2"))
print(f"naive first half = {naive} points, which is the whole game")
score keys: ['p1', 'p2'] -> halves
p1+p2   line   77.5  close O 1.961 / U 1.862  scored  73  -> Under wins
result  line  163.0  close O 1.925 / U 1.892  scored 156  -> Under wins
naive first half = 156 points, which is the whole game

The main line is the rung where Over and Under sit closest together, because mainLine is not reliable across books. Match outcomes on outcomeName, never on array position; the catalogue flips Over and Under from rung to rung. If a men’s fixture goes to overtime, /scores adds an overtime key that holds the running total, so result stays the key for full-game totals.

Step 4: Tier the Slate by Pinnacle’s Limit

This loop takes a day’s fixtures, reads Pinnacle’s first moneyline snapshot and its closing limit, and labels each board. We ran it on six fixtures from the 7 February slate, three from each wave.

import os, time, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"

def get(path, **params):
    while True:
        r = requests.get(f"{BASE_URL}/{path}", params={"apiKey": API_KEY, **params})
        if r.status_code == 429:
            time.sleep(r.json()["error"].get("retryMs", 5000) / 1000 + 0.5)
            continue
        return r

# Saturday 7 Feb 2026: the men's slate, tip-off times from /fixtures
slate = get("fixtures", sportId=11, tournamentId=648,
            **{"from": "2026-02-07", "to": "2026-02-08"}).json()
tips = {f["fixtureId"]: f for f in slate}

SAMPLE = ["id1100064863666695",   # Wake Forest v Louisville
          "id1100064863690411",   # Marquette v Butler
          "id1100064863151769",   # Quinnipiac v Niagara
          "id1100064864185633",   # Morgan State v Maryland Eastern Shore
          "id1100064864187413",   # Grambling State v Alabama State
          "id1100064863063783"]   # Southeastern Louisiana v Houston Christian

for fid in SAMPLE:
    f = tips[fid]
    r = get("historical-odds", fixtureId=fid, bookmakers="pinnacle")
    time.sleep(4.6)
    if r.status_code != 200:
        print(f"{fid}  HTTP {r.status_code}, Pinnacle history absent")
        continue
    legs = r.json()["bookmakers"]["pinnacle"]["markets"]["111"]["outcomes"].values()
    posted, limits = [], []
    for leg in legs:
        snaps = [s for s in leg["players"]["0"] if s["createdAt"] < f["startTime"]]
        posted.append(snaps[0]["createdAt"][11:16])
        limits.append([s for s in snaps if s["active"]][-1]["limit"])
    base = min(limits)                       # Pinnacle's base sits on the shorter-limit leg
    tier = "full board" if base >= 1500 else "small-conference board"
    name = f"{f['participant1Name']} v {f['participant2Name']}"
    print(f"{name:57} posted {min(posted)} UTC  base limit {base:6.0f}  {tier}")
Wake Forest Demon Deacons v Louisville Cardinals          posted 13:04 UTC  base limit   3000  full board
Marquette Golden Eagles v Butler Bulldogs                 posted 13:10 UTC  base limit   3000  full board
Quinnipiac Bobcats v Niagara Purple Eagles                posted 13:14 UTC  base limit   3000  full board
Morgan State Bears v Maryland Eastern Shore Hawks         posted 15:25 UTC  base limit    272  small-conference board
Grambling State Tigers v Alabama State Hornets            posted 15:56 UTC  base limit    250  small-conference board
Southeastern Louisiana Lions v Houston Christian Huskies  posted 15:51 UTC  base limit    250  small-conference board

/historical-odds needs a 4.6-second pause between successful calls on this payload size, so a Pinnacle pass over a full Saturday slate takes about 20 minutes. Run it overnight and cache the result per fixture.

What Changes When the Season Opens

Swap /historical-odds for /v4/odds on the same fixture IDs once books post. The live endpoint returns every bookmaker the key can read in one call, under the top-level key bookmakerOdds rather than bookmakers. On the full board, filter on the price-level active flag before you rank margins; a wide board carries many posted but suspended prices. For in-play first-half totals, the WebSocket feed pushes each price change instead of making you poll for it.

The basketball market catalogue is the same one the NBA uses, so the full-game ladders and the regulation and overtime twins behave as the NBA alternate lines post describes. The halves format is the one college-specific rule.

Get the Board Before Opening Night

OddsPapi carries 350+ bookmakers, Pinnacle and the US exchanges included, and the free tier includes the historical archive you need to replay last season. Get a free API key, pull February’s closing lines this week, and have your college basketball model priced before the first tip in November.

FAQ

What is the tournament ID for men’s college basketball?

Men’s Division I regular season is tournamentId 648, NCAA Regular Season, under sportId 11. The women’s regular season is 21384. Pass the ID to /v4/fixtures to pull a month of fixtures in one call; on 9 October 2026 it listed 466 men’s fixtures for 2 to 8 November.

How many bookmakers price a college basketball fixture?

On 7 February 2026, 156 books priced Auburn v Alabama, 157 priced Northern Iowa v Bradley, 142 priced NC A&T v Campbell and 124 priced Morgan State v Maryland Eastern Shore. Dedupe on the price pair before you average: 146 moneyline quotes on Auburn v Alabama held 44 distinct pairs.

When does Pinnacle post college basketball lines?

On the day of the fixture. On the 7 February 2026 slate Pinnacle posted 28 boards between 13:02 and 13:37 UTC with a base moneyline limit of 1,500 to 3,211, and a second wave of 9 small-conference boards between 15:25 and 15:57 UTC with a base limit of 250 to 272.

Why does my first-half grader pay the wrong side?

Men’s college basketball plays two halves. The first-half markets carry period p1+p2, the NBA quarter label, while /v4/scores returns p1 and p2 as the two halves. Grade a men’s first-half market on scores p1 alone. Women’s college fixtures play quarters and return p1 to p4.

Can I get last season’s college basketball closing lines for free?

Yes. /historical-odds returns every snapshot for a played fixture on the free tier, three bookmaker slugs per call. Take the last active snapshot before startTime as the close, and pause 4.6 seconds between successful calls.