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 URL | https://enterprise.propprofessor.com |
| WebSocket URL | wss://enterprise.propprofessor.com/v1/ws |
| Content type | application/json (requests and responses) |
| Auth | Authorization: Bearer YOUR_API_KEY |
| Support | enterprise@propprofessor.com |
Authentication
Send your key as a bearer token on every HTTP request and on the WebSocket upgrade.
Authorization: Bearer YOUR_API_KEYWebSocket 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:
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
| Field | Type | Meaning |
|---|---|---|
id | string | Selection id (above). |
sportsbook | string | Canonical book name, as listed by /v1/sportsbooks. |
market | string | Market 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. |
selection | string | The outcome, e.g. Boston Celtics, Jayson Tatum Over 27.5. |
odds | integer | null | American odds. -180 = risk 180 to win 100. null only on PrizePicks (Alt) rows (below). |
line | number | null | Handicap, total or prop line. null for moneyline-style markets. |
liquidity | number | Available size at this price for exchanges and prediction markets; 0 when the source does not report one. |
depth | array | null | Exchange 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. |
updatedMs | integer | Epoch 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
| Field | Type | Meaning |
|---|---|---|
gameId | string | Stable game id. |
league | string | League key, e.g. NBA, NFL Futures. |
sport | string | football, basketball, baseball, hockey, soccer, tennis, golf, mma, esports. |
kind | string | games or futures. |
leagueName | string | null | Competition within a bucket league: MLS, ATP Montreal, Premier League, … (Soccer and Tennis). |
homeTeam / awayTeam | string | Canonical team or player names. For futures: the season token and the market name. |
startInt / startTime | integer / string | Scheduled start as epoch seconds and ISO-8601. |
isLive | boolean | In play. Flips once and never reverts. |
sportsbooks | string[] | Books currently pricing this game. |
selections | integer | Number of live selections across all books. |
live | object | null | Live 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:
{
"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
| League | Sport | Notes |
|---|---|---|
NFL, NCAAF, CFL | football | |
NBA, WNBA, NCAAB | basketball | |
MLB, NPB, KBO | baseball | |
NHL | hockey | |
Soccer | soccer | one bucket; leagueName carries the competition |
Tennis | tennis | one bucket; leagueName carries the tournament (doubles suffixed Doubles) |
PGA | golf | tournament matchups and outrights |
UFC | mma | |
LoL, CSGO, Valorant, DOTA, COD | esports | |
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.
{
"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.
{
"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.
| Query | Required | Meaning |
|---|---|---|
league | yes | League key from /v1/leagues. |
live | no | true for in-play only, false for prematch only. |
{
"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).
| Parameter | Required | Meaning |
|---|---|---|
gameIds | one of | Game ids, up to your key's cap (request.maxGameIds on /v1/limits). Leagues may be mixed. Use the POST body for long lists. |
league | one of | Every game in the league. Combine with filters — a full NFL Sunday board is tens of thousands of selections. |
markets | no | Only these markets. |
sportsbooks | no | Only these books. |
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"{
"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.
{
"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.
{
"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.
| Field | Meaning |
|---|---|
keyId | Opaque id of your key (what our logs and support reference). |
key.expiresAt / key.expiresInSec | When your key stops working (ISO-8601 UTC) and the seconds until then; both null for keys without a fixed term. |
http.general | Your rolling one-minute HTTP budget: limit, windowSec, used, remaining, resetSec — the same numbers the RateLimit-* headers report. |
websocket.maxConnections / connections | Concurrent sockets allowed per key, and how many you hold open now. |
websocket.maxSubscriptionsPerConnection / subscriptions | Subscriptions allowed per socket, and how many you hold across all sockets. |
websocket.maxBufferBytes | Send-buffer size behind which a slow consumer is disconnected (close code 1013). |
websocket.maxMessageBytes | Largest frame you may send. |
websocket.pingIntervalSec | How often the server pings; answer pongs to stay connected. |
request.maxGameIds | Game ids per /v1/odds request or per game-id subscription. |
request.maxBodyBytes | Largest request body. |
perAddress | Limits enforced by the edge per client address rather than per key: failed authentications per minute, HTTP burst, sockets. |
timeMs | Server 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:
{ "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.
{ "action": "subscribe", "id": "nba-mains", "league": "NBA", "markets": ["Moneyline", "Point Spread", "Total Points"] }{ "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"] }| Field | Type | Notes |
|---|---|---|
id | string | 1–64 chars. Re-using an id replaces that subscription (fresh snapshot). |
league | string | Follow every game in the league, including ones that appear later. |
gameIds | string[] | Follow specific games (up to your key's game-id cap). Provide league or gameIds. |
sportsbooks | string[] | Optional book filter. |
markets | string[] | Optional market filter. |
games | boolean | Send 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:
{
"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:
{
"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):
{ "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:
{ "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
{ "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
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/oddsNode.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
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
| Status | code | When |
|---|---|---|
400 | invalid_request, invalid_json, invalid_league, invalid_game_id, too_many_games | Malformed or unsupported input. |
401 | unauthorized | Missing or invalid key. |
404 | not_found | /v1/games/{id} for a game nobody is pricing, or an unknown route. |
413 | payload_too_large | POST body over 1 MB. |
429 | rate_limited, auth_throttled | Over 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. |
500 | internal | Our side. Safe to retry with backoff. |
Best practices
- Use the WebSocket for anything you refresh more than once a minute. Polling
/v1/oddson a live board burns your budget and is still slower than the push. - Key your state on
gameId+ selectionid. Both are stable; never parse them. - Filter server-side.
marketsandsportsbooksshrink both snapshots and updates. Market names differ by sport (Point Spread/Run Line/Match Handicap);GET /v1/marketslists what a league offers right now. - Watch
seq. A gap means resubscribe. A reconnect always means resubscribe. - Cache
/v1/leaguesand/v1/sportsbooks— they change a few times a year. - Gate on
live.isTimeout/live.isPeriodEndif your strategy should not fire during stoppages.
Changelog
- 2.1.1 — Fixed-term keys:
401 key_expiredafter the term, sockets closed with code4001, expiry visible inGET /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 —
/v1REST surface and/v1/ws: named subscriptions with league-wide follow, sequence numbers, game lifecycle and live-state frames,sportsbooksfilters, futures leagues,/v1/markets,/v1/status. Updates are pushed straight from the odds pipeline. The pre-2026 root routes and/wswere retired.
