Odds API reference

Read-only, real-time access to the Prop Professor odds aggregate: every price we track across 50+ sportsbooks, exchanges, prediction markets and DFS apps, for 19 leagues plus their futures boards. 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.

Rate limits

1,000 HTTP requests per minute per key unless your plan says otherwise. Every response carries the standard headers:

http
RateLimit-Limit: 1000
RateLimit-Remaining: 987
RateLimit-Reset: 42

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.

Per key you may hold up to 10 concurrent WebSocket connections, each with up to 25 subscriptions.

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: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, Spread, Total, Player Points, 1st Half Spread.
selectionstringThe outcome, e.g. Boston Celtics, Jayson Tatum Over 27.5.
oddsintegerAmerican odds. -180 = risk 180 to win 100.
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.
updatedMsintegerEpoch milliseconds when this price last changed at the source.

DFS pick'em apps (PrizePicks, Underdog, …) publish a line without a price; their selections carry the app's multiplier-implied odds where one exists, and the market / selection carry the same names as the sportsbooks so you can 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

A price is only served while the book that produced it is heartbeating for that game: 5 seconds for prematch, 3 seconds for live games, 30 seconds for futures boards. When a book stops reporting a game its selections are dropped (and pushed to subscribers as removals) within that window, 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 Futuresoutright 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": "prediction" },
    { "name": "PrizePicks", "category": "dfs" }
  ]
}

Categories: sportsbook, exchange, prediction, dfs. 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 ofUp to 500 game ids. Leagues may be mixed.
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,Spread"
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:Spread:Miami_Heat_+4.5", "sportsbook": "FanDuel", "market": "Spread",
          "selection": "Miami Heat +4.5", "odds": -110, "line": 4.5, "liquidity": 0, "updatedMs": 1771337460102 }
      ]
    }
  ],
  "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 /healthz

Unauthenticated liveness probe: {"ok":true,"version":"2.0.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.0.0", "maxSubscriptions": 25 }

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", "Spread", "Total"] }
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 500). 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 or liquidity 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 more than 8 MB behind, 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":["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", "Spread", "Total"] }));
});

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_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.
429rate_limited, auth_throttledOver your request budget, or too many failed logins.
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.
  • 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.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.