Odds API reference

Read-only, real-time access to the Prop Professor odds aggregate: every price we track across 35+ sportsbooks, exchanges, prediction markets and DFS apps, for 19 leagues plus their futures boards. Live games update every 500 ms and prematch every 1–2 s. Discovery is REST; anything that moves is pushed over a WebSocket.

HTTP base URLhttps://enterprise.propprofessor.com
WebSocket URLwss://enterprise.propprofessor.com/v1/ws
Content typeapplication/json (requests and responses)
AuthAuthorization: Bearer YOUR_API_KEY
Supportenterprise@propprofessor.com

Authentication

Send your key as a bearer token on every HTTP request and on the WebSocket upgrade.

http
Authorization: Bearer YOUR_API_KEY

WebSocket clients that cannot set headers (browsers) may pass ?token=YOUR_API_KEY on the upgrade URL instead. Prefer the header wherever you can: query strings end up in access logs.

A missing or invalid key returns 401 with {"error":"Missing or invalid API key","code":"unauthorized"}. Repeated failures from one address are throttled.

Keys issued for a fixed term (evaluations) stop working at the instant in your agreement: HTTP requests then return 401 with {"error":"API key expired","code":"key_expired"}, new WebSocket upgrades are refused, and any open socket is closed with code 4001 and reason API key expired within about 30 seconds. GET /v1/limits shows the expiry under key.expiresAt (null for keys without a term).

Rate limits

Your HTTP request budget, WebSocket connection and subscription caps, and request-size caps are set per key by your API agreement. The HTTP budget is sized for discovery and occasional pulls: anything continuous belongs on the WebSocket, which is not metered. Every response carries the standard headers:

http
RateLimit-Limit: <your budget per minute>
RateLimit-Remaining: <what is left in the current window>
RateLimit-Reset: <seconds until the window resets>

Over the limit you get 429 with code: "rate_limited"; wait RateLimit-Reset seconds. WebSocket messages are not metered — one socket replaces what would otherwise be thousands of polls, which is the point. GET /v1/limits returns every cap on your key together with your current usage, so read it from there rather than hard-coding numbers.

Concepts

Game ids

A game id is an opaque string. It is stable for the game's entire life, including when the game goes live.

ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400

Use ids exactly as returned by /v1/games; never construct or parse them. Liveness comes from the game's isLive field, never from the id.

Selection ids

Every price has an id of the form {sportsbook}:{Market}:{Selection} with underscores for spaces:

FanDuel:Moneyline:Boston_Celtics DraftKings:Player_Points:Jayson_Tatum_Over_27.5 Pinnacle:Point_Spread:Miami_Heat_+3.5

The id is unique within a game and is the key for every update and removal you receive.

Selection object

FieldTypeMeaning
idstringSelection id (above).
sportsbookstringCanonical book name, as listed by /v1/sportsbooks.
marketstringMarket name, e.g. Moneyline, Point Spread, Total Points, Run Line, Player Points, Point Spread - 1st Half. Names are sport-specific; list them with /v1/markets.
selectionstringThe outcome, e.g. Boston Celtics, Jayson Tatum Over 27.5.
oddsinteger | nullAmerican odds. -180 = risk 180 to win 100. null only on PrizePicks (Alt) rows (below).
linenumber | nullHandicap, total or prop line. null for moneyline-style markets.
liquiditynumberAvailable size at this price for exchanges and prediction markets; 0 when the source does not report one.
deptharray | nullExchange rows only (category: "exchange"). The orderbook ladder behind odds: [{ "odds": -172, "liquidity": 640 }, …], best price first, up to three rungs. The first rung is the quoted price with the cumulative size available at it, the rest are the next worse prices with their own size. null when the venue published no ladder. The key is absent on sportsbook and DFS rows.
updatedMsintegerEpoch milliseconds when this price last changed at the source.

DFS apps price through payout multipliers rather than per-selection odds, so odds on a DFS row is the app's multiplier expressed as American odds. Multiplier apps (Sleeper, ParlayPlay, HotStreak, Boom, Chalkboard) and the (Alt) boards for Underdog, Dabble, DraftKings6 and Betr price every pick from the app's own multiplier. Standard pick'em rows (PrizePicks, Underdog, Dabble, Betr, DraftKings6) carry the per-leg break-even of that app's best slip type, which is constant per app (per league for DraftKings6). The one exception is PrizePicks (Alt) (demons and goblins): PrizePicks prices those per slip, not per leg, so no per-selection price exists and odds is null. market and selection use the same names as the sportsbooks, so a prop at a book and the same prop at a DFS app compare directly.

Game object

FieldTypeMeaning
gameIdstringStable game id.
leaguestringLeague key, e.g. NBA, NFL Futures.
sportstringfootball, basketball, baseball, hockey, soccer, tennis, golf, mma, esports.
kindstringgames or futures.
leagueNamestring | nullCompetition within a bucket league: MLS, ATP Montreal, Premier League, … (Soccer and Tennis).
homeTeam / awayTeamstringCanonical team or player names. For futures: the season token and the market name.
startInt / startTimeinteger / stringScheduled start as epoch seconds and ISO-8601.
isLivebooleanIn play. Flips once and never reverts.
sportsbooksstring[]Books currently pricing this game.
selectionsintegerNumber of live selections across all books.
liveobject | nullLive game state (below) while in play, otherwise null.

Live game state

For in-play games live carries scores and clock, patched every couple of seconds:

json
{
  "homeScore": 54, "awayScore": 49,
  "period": 3, "periodName": "3rd Quarter", "inningHalf": null,
  "clock": "7:12", "clockRunning": true,
  "isPeriodEnd": false, "isTimeout": false,
  "status": "3Q", "periodScores": { "p1": { "home": 28, "away": 22 } },
  "gameScore": null, "updatedMs": 1771540233512
}

isTimeout and isPeriodEnd are the flags our own tools gate on; use them to pause a live strategy during breaks. Tennis games use gameScore for the in-game points.

Liveness and staleness

Live games update every 500 ms and prematch every 1–2 s. When a book stops reporting a game its selections are dropped (and pushed as removals), and come back as upserts the moment it resumes. You never receive stale numbers dressed up as current ones.

A game is present while at least one book is alive on it. Games whose every book has dropped disappear from /v1/games and produce a game removed frame.

Leagues

LeagueSportNotes
NFL, NCAAF, CFLfootball
NBA, WNBA, NCAABbasketball
MLB, NPB, KBObaseball
NHLhockey
Soccersoccerone bucket; leagueName carries the competition
Tennistennisone bucket; leagueName carries the tournament (doubles suffixed Doubles)
PGAgolftournament matchups and outrights
UFCmma
LoL, CSGO, Valorant, DOTA, CODesports
NFL Futures, NBA Futures, MLB Futures, NHL Futures, WNBA Futures, NCAAF Futures, NCAAB Futures, Soccer Futures, Tennis Futures, PGA Futures—outright boards, kind: "futures"

Out-of-season leagues are still valid but hold no games. GET /v1/leagues reports current counts.

HTTP endpoints

GET /v1/leagues

Every league the API serves, with what is on the board right now.

json
{
  "leagues": [
    { "league": "NBA", "sport": "basketball", "kind": "games", "games": 11, "liveGames": 3 },
    { "league": "NBA Futures", "sport": "basketball", "kind": "futures", "games": 6, "liveGames": 0 }
  ]
}

GET /v1/sportsbooks

Every source we track, with a category you can group on.

json
{
  "sportsbooks": [
    { "name": "FanDuel", "category": "sportsbook" },
    { "name": "Pinnacle", "category": "sportsbook" },
    { "name": "NoVigApp", "category": "exchange" },
    { "name": "Kalshi", "category": "exchange" },
    { "name": "PrizePicks", "category": "dfs" }
  ]
}

Categories: sportsbook, exchange, dfs. Exchanges cover the betting exchanges and the prediction markets (Kalshi, Polymarket, Polymarket US), which is also the set of books that report liquidity. Names are exactly what appears in the sportsbook field and in selection ids.

GET /v1/games

Games with live odds in a league, ordered by start time.

QueryRequiredMeaning
leagueyesLeague key from /v1/leagues.
livenotrue for in-play only, false for prematch only.
json
{
  "league": "NBA",
  "games": [
    {
      "gameId": "ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400",
      "league": "NBA", "sport": "basketball", "kind": "games", "leagueName": null,
      "homeTeam": "Boston Celtics", "awayTeam": "Miami Heat",
      "startInt": 1771538400, "startTime": "2026-02-19T00:00:00.000Z",
      "isLive": false, "sportsbooks": ["DraftKings", "FanDuel", "Pinnacle"],
      "selections": 1834, "live": null
    }
  ]
}

GET /v1/games/{gameId}

One game, with its current live state. 404 (code: "not_found") when no book is currently pricing it.

GET|POST /v1/odds

Current prices for a set of games, or for a whole league. Parameters go in the query string (comma-separated lists) or in a JSON body (arrays).

ParameterRequiredMeaning
gameIdsone ofGame ids, up to your key's cap (request.maxGameIds on /v1/limits). Leagues may be mixed. Use the POST body for long lists.
leagueone ofEvery game in the league. Combine with filters — a full NFL Sunday board is tens of thousands of selections.
marketsnoOnly these markets.
sportsbooksnoOnly these books.
bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://enterprise.propprofessor.com/v1/odds?gameIds=ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400&markets=Moneyline,Point%20Spread"
json
{
  "games": [
    {
      "gameId": "ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400",
      "league": "NBA", "homeTeam": "Boston Celtics", "awayTeam": "Miami Heat",
      "isLive": false, "startInt": 1771538400, "startTime": "2026-02-19T00:00:00.000Z",
      "sport": "basketball", "kind": "games", "leagueName": null,
      "sportsbooks": ["DraftKings", "FanDuel"], "selections": 1834, "live": null,
      "odds": [
        { "id": "FanDuel:Moneyline:Boston_Celtics", "sportsbook": "FanDuel", "market": "Moneyline",
          "selection": "Boston Celtics", "odds": -180, "line": null, "liquidity": 0, "updatedMs": 1771337463308 },
        { "id": "FanDuel:Point_Spread:Miami_Heat_+4.5", "sportsbook": "FanDuel", "market": "Point Spread",
          "selection": "Miami Heat +4.5", "odds": -110, "line": 4.5, "liquidity": 0, "updatedMs": 1771337460102 },
        { "id": "Prophet:Moneyline:Boston_Celtics", "sportsbook": "Prophet", "market": "Moneyline",
          "selection": "Boston Celtics", "odds": -172, "line": null, "liquidity": 640, "updatedMs": 1771337462950,
          "depth": [ { "odds": -172, "liquidity": 640 }, { "odds": -178, "liquidity": 210 }, { "odds": -185, "liquidity": 1200 } ] }
      ]
    }
  ],
  "missing": []
}

Games with no matching selections are omitted. Ids that are valid but not currently on the board are listed in missing so you can tell "no odds" from "unknown id" (unknown-format ids are a 400).

GET /v1/markets

The markets currently offered in a league, most populated first — handy for building filters.

json
{
  "league": "NBA",
  "markets": [
    { "market": "Player Points", "selections": 4210, "sportsbooks": ["DraftKings", "FanDuel", "PrizePicks", "Underdog"] },
    { "market": "Moneyline", "selections": 396, "sportsbooks": ["BetMGM", "Caesars", "DraftKings", "FanDuel", "Pinnacle"] }
  ]
}

GET /v1/status

Per-league health: which books are online (reported within the last 30 seconds) and whether the feed is flowing.

json
{
  "server": { "version": "2.0.0", "uptimeSec": 86213, "timeMs": 1771540233512 },
  "leagues": {
    "NBA": {
      "active": true, "games": 11, "liveGames": 3,
      "sportsbooks": { "FanDuel": "online", "DraftKings": "online", "BallyBet": "offline" },
      "stream": { "lastEventMs": 1771540233401, "lastSnapshotMs": 1771540200114, "eventsApplied": 8812304 }
    },
    "NFL": { "active": true, "games": 0, "liveGames": 0, "sportsbooks": {}, "stream": { "lastEventMs": null, "lastSnapshotMs": 1771540200114, "eventsApplied": 0 } }
  }
}

GET /v1/limits

Every cap that applies to your key, as set by your API agreement, with your current usage where the server tracks it. The request itself counts as one HTTP hit.

FieldMeaning
keyIdOpaque id of your key (what our logs and support reference).
key.expiresAt / key.expiresInSecWhen your key stops working (ISO-8601 UTC) and the seconds until then; both null for keys without a fixed term.
http.generalYour rolling one-minute HTTP budget: limit, windowSec, used, remaining, resetSec — the same numbers the RateLimit-* headers report.
websocket.maxConnections / connectionsConcurrent sockets allowed per key, and how many you hold open now.
websocket.maxSubscriptionsPerConnection / subscriptionsSubscriptions allowed per socket, and how many you hold across all sockets.
websocket.maxBufferBytesSend-buffer size behind which a slow consumer is disconnected (close code 1013).
websocket.maxMessageBytesLargest frame you may send.
websocket.pingIntervalSecHow often the server pings; answer pongs to stay connected.
request.maxGameIdsGame ids per /v1/odds request or per game-id subscription.
request.maxBodyBytesLargest request body.
perAddressLimits enforced by the edge per client address rather than per key: failed authentications per minute, HTTP burst, sockets.
timeMsServer time of the response.

GET /healthz

Unauthenticated liveness probe: {"ok":true,"version":"2.1.0","uptimeSec":123}.

WebSocket

Connect once, subscribe to what you care about, and receive a snapshot followed by deltas. Frames are JSON text messages.

Connect

wss://enterprise.propprofessor.com/v1/ws Authorization: Bearer YOUR_API_KEY

or wss://enterprise.propprofessor.com/v1/ws?token=YOUR_API_KEY. The server sends a hello on connect:

json
{ "type": "hello", "version": "2.1.0", "maxSubscriptions": <your per-connection subscription cap> }

The server pings every 30 seconds and closes connections that miss a pong. Most client libraries answer pings automatically.

Subscribe

Each subscription has an id you choose and follows either a whole league or a list of games. Optional sportsbooks and markets filters apply to the snapshot and every update.

json
{ "action": "subscribe", "id": "nba-mains", "league": "NBA", "markets": ["Moneyline", "Point Spread", "Total Points"] }
json
{ "action": "subscribe", "id": "two-games",
  "gameIds": ["ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400", "ODDS:NFL:GAME:Kansas_City_Chiefs:Buffalo_Bills:1771545600"],
  "sportsbooks": ["Pinnacle", "DraftKings", "FanDuel"] }
FieldTypeNotes
idstring1–64 chars. Re-using an id replaces that subscription (fresh snapshot).
leaguestringFollow every game in the league, including ones that appear later.
gameIdsstring[]Follow specific games (up to your key's game-id cap). Provide league or gameIds.
sportsbooksstring[]Optional book filter.
marketsstring[]Optional market filter.
gamesbooleanSend game lifecycle frames (default true).

Frames

Every frame for a subscription carries its id and a seq that increments by one per frame. If you ever see a gap, resubscribe: something was dropped and a fresh snapshot is the only safe recovery.

snapshot — the full current state, once per subscribe:

json
{
  "type": "snapshot", "id": "nba-mains", "seq": 1,
  "games": [
    { "gameId": "ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400", "league": "NBA", "isLive": false,
      "homeTeam": "Boston Celtics", "awayTeam": "Miami Heat", "startInt": 1771538400, "startTime": "2026-02-19T00:00:00.000Z",
      "sport": "basketball", "kind": "games", "leagueName": null, "sportsbooks": ["DraftKings", "FanDuel"], "selections": 1834, "live": null,
      "odds": [ { "id": "FanDuel:Moneyline:Boston_Celtics", "sportsbook": "FanDuel", "market": "Moneyline", "selection": "Boston Celtics",
                  "odds": -180, "line": null, "liquidity": 0, "updatedMs": 1771337463308 } ] }
  ]
}

update — what changed on one game. Merge upserts by id; delete removals:

json
{
  "type": "update", "id": "nba-mains", "seq": 2,
  "gameId": "ODDS:NBA:GAME:Boston_Celtics:Miami_Heat:1771538400",
  "upserts": [ { "id": "FanDuel:Moneyline:Boston_Celtics", "sportsbook": "FanDuel", "market": "Moneyline", "selection": "Boston Celtics",
                 "odds": -190, "line": null, "liquidity": 0, "updatedMs": 1771337470891 } ],
  "removals": [ "DraftKings:Moneyline:Miami_Heat" ]
}

An upsert is a selection whose price, line, liquidity or (on exchange rows) depth changed, or that just became visible (new listing, or its book came back). A removal is a selection that was taken down or whose book stopped reporting. Updates are coalesced over a ~50 ms window, so a burst of changes on one game arrives as one frame.

game — lifecycle of a game in your subscription (league subscriptions get these for games that appear and disappear; game-id subscriptions for the games they follow):

json
{ "type": "game", "id": "nba-mains", "seq": 3, "event": "added",
  "game": { "gameId": "ODDS:NBA:GAME:LA_Lakers:Denver_Nuggets:1771545600", "league": "NBA", "isLive": false, "…": "…" } }

event is added (first odds are about to follow), updated (went live, or its live state changed — scores, clock, timeouts) or removed (no book prices it any more; its removals were sent just before). Set "games": false on subscribe to skip these.

unsubscribed, pong and error:

json
{ "type": "unsubscribed", "id": "nba-mains" }
{ "type": "pong", "timeMs": 1771540233512 }
{ "type": "error", "id": "nba-mains", "code": "invalid_league", "message": "Unknown league: NBAA" }

Error codes: invalid_json, invalid_request, invalid_league, invalid_game_id, too_many_subscriptions, unknown_action, unavailable. The connection stays open after an error.

Unsubscribe and ping

json
{ "action": "unsubscribe", "id": "nba-mains" }
{ "action": "ping" }

Backpressure and reconnects

If your client falls too far behind (websocket.maxBufferBytes on /v1/limits), the server closes the socket with code 1013 rather than buffering without bound. On any close: reconnect, resubscribe, and treat the new snapshot as truth. Snapshots for a whole league can be large — subscribe with markets / sportsbooks filters when you do not need the full board.

Quick examples

cURL

bash
export API_KEY=...
BASE=https://enterprise.propprofessor.com

curl -H "Authorization: Bearer $API_KEY" $BASE/v1/leagues
curl -H "Authorization: Bearer $API_KEY" "$BASE/v1/games?league=NFL&live=true"
curl -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
     -d '{"league":"NFL","markets":["Point Spread"],"sportsbooks":["Pinnacle","DraftKings"]}' \
     $BASE/v1/odds

Node.js

js
import WebSocket from "ws";

const ws = new WebSocket("wss://enterprise.propprofessor.com/v1/ws", {
  headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
const board = new Map(); // gameId -> Map(selectionId -> selection)

ws.on("open", () => {
  ws.send(JSON.stringify({ action: "subscribe", id: "nba", league: "NBA", markets: ["Moneyline", "Point Spread", "Total Points"] }));
});

ws.on("message", (raw) => {
  const msg = JSON.parse(raw);
  if (msg.type === "snapshot") {
    board.clear();
    for (const g of msg.games) board.set(g.gameId, new Map(g.odds.map((s) => [s.id, s])));
  } else if (msg.type === "update") {
    const sels = board.get(msg.gameId) ?? new Map();
    for (const s of msg.upserts) sels.set(s.id, s);
    for (const id of msg.removals) sels.delete(id);
    board.set(msg.gameId, sels);
  } else if (msg.type === "game" && msg.event === "removed") {
    board.delete(msg.game.gameId);
  }
});

Python

python
import asyncio, json, os, websockets

URL = "wss://enterprise.propprofessor.com/v1/ws"
HEADERS = {"Authorization": f"Bearer {os.environ['API_KEY']}"}

async def main():
    async with websockets.connect(URL, additional_headers=HEADERS) as ws:
        await ws.send(json.dumps({"action": "subscribe", "id": "nfl", "league": "NFL",
                                  "sportsbooks": ["Pinnacle", "DraftKings", "FanDuel"]}))
        expected = {}
        async for raw in ws:
            msg = json.loads(raw)
            if "seq" in msg:
                if expected.get(msg["id"], msg["seq"]) != msg["seq"]:
                    raise RuntimeError("gap — resubscribe")
                expected[msg["id"]] = msg["seq"] + 1
            if msg["type"] == "update":
                for sel in msg["upserts"]:
                    print(sel["sportsbook"], sel["market"], sel["selection"], sel["odds"])

asyncio.run(main())

Errors

StatuscodeWhen
400invalid_request, invalid_json, invalid_league, invalid_game_id, too_many_gamesMalformed or unsupported input.
401unauthorizedMissing or invalid key.
404not_found/v1/games/{id} for a game nobody is pricing, or an unknown route.
413payload_too_largePOST body over 1 MB.
429rate_limited, auth_throttledOver your request budget, or too many failed logins.
503—Edge burst limit: too many requests in flight at once from one address. Plain-text body from the proxy; back off and retry.
500internalOur side. Safe to retry with backoff.

Best practices

  • Use the WebSocket for anything you refresh more than once a minute. Polling /v1/odds on a live board burns your budget and is still slower than the push.
  • Key your state on gameId + selection id. Both are stable; never parse them.
  • Filter server-side. markets and sportsbooks shrink both snapshots and updates. Market names differ by sport (Point Spread / Run Line / Match Handicap); GET /v1/markets lists what a league offers right now.
  • Watch seq. A gap means resubscribe. A reconnect always means resubscribe.
  • Cache /v1/leagues and /v1/sportsbooks — they change a few times a year.
  • Gate on live.isTimeout / live.isPeriodEnd if your strategy should not fire during stoppages.

Changelog

  • 2.1.1 — Fixed-term keys: 401 key_expired after the term, sockets closed with code 4001, expiry visible in GET /v1/limits (key.expiresAt).
  • 2.1 — GET /v1/limits: every cap on your key with live usage. Budgets are now per API agreement; the WebSocket is the intended path for anything continuous.
  • 2.0 — /v1 REST surface and /v1/ws: named subscriptions with league-wide follow, sequence numbers, game lifecycle and live-state frames, sportsbooks filters, futures leagues, /v1/markets, /v1/status. Updates are pushed straight from the odds pipeline. The pre-2026 root routes and /ws were retired.