# 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](mailto:enterprise@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: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 | 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:

```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

| 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.

```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.

| Query | Required | Meaning |
|---|---|---|
| `league` | yes | League key from `/v1/leagues`. |
| `live` | no | `true` 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).

| 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. |

```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 }
      ]
    }
  ],
  "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", "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"] }
```

| 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:

```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":["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

| 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/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.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.
