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 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.
Rate limits
1,000 HTTP requests per minute per key unless your plan says otherwise. Every response carries the standard headers:
RateLimit-Limit: 1000
RateLimit-Remaining: 987
RateLimit-Reset: 42Over 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
| 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, Spread, Total, Player Points, 1st Half Spread. |
selection | string | The outcome, e.g. Boston Celtics, Jayson Tatum Over 27.5. |
odds | integer | American odds. -180 = risk 180 to win 100. |
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. |
updatedMs | integer | Epoch 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
| 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
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
| 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": "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.
| 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 | Up to 500 game ids. Leagues may be mixed. |
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,Spread"{
"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.
{
"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 /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:
{ "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.
{ "action": "subscribe", "id": "nba-mains", "league": "NBA", "markets": ["Moneyline", "Spread", "Total"] }{ "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 500). 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 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):
{ "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 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
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/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", "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
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_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. |
429 | rate_limited, auth_throttled | Over your request budget, or too many failed logins. |
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. - 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.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.
