Skip to main content
The Books API gives you live sports data three ways:
  • Live scores, clocks, periods, and play state for every currently in-play game
  • Real-time odds from 100+ sportsbooks including DraftKings, FanDuel, BetMGM, bet365, Polymarket, Novig, Sporttrade, Polymarket (USA), and more
  • Polynode canonical slugs so the same game is identified the same way here as it is on polynode’s sports catalog
https://books.polynode.dev
wss://books.polynode.dev

60-second quickstart

# Authenticate with your API key (query param or Authorization header)
KEY=pn_live_YOUR_KEY

# 1. What sports are in-play right now?
curl "https://books.polynode.dev/v1/sports?key=$KEY"

# 2. Get every live game across every sport
curl "https://books.polynode.dev/v1/games/live?key=$KEY"

# 3. Get moneyline for one live game, comparing FanDuel vs Polymarket prices
curl "https://books.polynode.dev/v1/games/nba-por-phx-2026-04-15/markets?market=moneyline&book=FanDuel,Polymarket&key=$KEY"

# 4. Subscribe to live score changes over WebSocket
wscat -c "wss://books.polynode.dev/ws/live?key=$KEY"
That’s the whole API. The rest of the docs are reference detail for each endpoint.

How a typical user lands here

You arrive knowing one of these things. Start with whichever applies:

I know a SPORT

Start with List Sports, pick a category, drill into leagues.

I know a LEAGUE NAME

Use List Leagues to find the pn_code by display name.

I know a TEAM

Use Search Games to fuzzy-match on team names.

I have a Polymarket URL

Extract the slug from the URL, hit Game Detail directly.

Authentication

Every endpoint under /v1/* and /ws/* requires a pn_live_* API key — the same key you use for the rest of polynode. Only /health is public. Two ways to pass the key:
# 1. Query parameter
curl "https://books.polynode.dev/v1/games/live?key=pn_live_YOUR_KEY"

# 2. Authorization header (preferred — avoids key in logs)
curl -H "Authorization: Bearer pn_live_YOUR_KEY" \
     "https://books.polynode.dev/v1/games/live"
For WebSocket connections, the query parameter is usually easier because browsers and WS libraries handle it uniformly:
wss://books.polynode.dev/ws/live?key=pn_live_YOUR_KEY
A missing or invalid key returns HTTP 401 before the WebSocket upgrade completes.

Three key identifiers you’ll see

Every game has multiple ways to identify it. Understanding them upfront will make the rest of the docs make sense.

game_id

Upstream’s canonical game id. Looks like 40664-16839-2026-04-14. Always present, stable, unique. Pass this to any /v1/games/{id} endpoint when you don’t have a polynode slug.

pn_slug

Polynode’s canonical game slug. Looks like lib-gar-est-2026-04-14{league}-{away}-{home}-{date}. Present for games where team resolution succeeded (~95% of major league games), null otherwise. Same slug that polynode’s own sports catalog uses. Pass this to any /v1/games/{id} endpoint interchangeably with game_id.

pn_league_code

Short league identifier. nba, nhl, epl, lib (Copa Libertadores), ipl (Indian Premier League), lol (League of Legends). Use List Leagues to discover the code for any league by its display name.

The 4 most important endpoints

EndpointPurposePayload size
GET /v1/games/liveEverything currently in-play~5-50 KB
GET /v1/games/{id}Full game + all market snapshots~50-200 KB
GET /v1/games/{id}/scoresJust the scores for one game (cheap)under 1 KB
wss://.../ws/livePush live score updatesstreaming

Discovery endpoints

When you don’t know what to query yet:
EndpointAnswers
GET /v1/sportsWhat sports do you cover?
GET /v1/leaguesWhat leagues, and how many live games in each?
GET /v1/market-typesWhat betting markets do you track?
GET /v1/booksWhich sportsbooks do you carry?
GET /v1/games/search?q=...Find a game by team name / slug / league

Sample end-to-end flow

Say you want to find real-time NBA player points props for a specific game:
# 1. Discover sports (you know "basketball" exists)
curl "https://books.polynode.dev/v1/sports?key=pn_live_YOUR_KEY"

# 2. Drill into basketball leagues
curl "https://books.polynode.dev/v1/leagues?sport=basketball&key=pn_live_YOUR_KEY"
# → finds pn_code: "nba"

# 3. Pull current NBA games
curl "https://books.polynode.dev/v1/leagues/nba/games?key=pn_live_YOUR_KEY"
# → pick a game by its pn_slug, e.g. "nba-mia-cha-2026-04-14"

# 4. Get full markets for that game
curl "https://books.polynode.dev/v1/games/nba-mia-cha-2026-04-14/markets?key=pn_live_YOUR_KEY"
# → 10 market types, one is "points" (player points)

# 5. For real-time updates, subscribe via WebSocket
wscat -c "wss://books.polynode.dev/ws/odds?key=pn_live_YOUR_KEY"
# → filter client-side for pn_market_type=points and pn_slug=nba-mia-cha-2026-04-14

What’s NOT in this API yet

Honest scope for v1:
  • Server-side subscription filters on the WebSocket channels — in v1 every subscriber receives every event on their channel and filters client-side. Subscription filters are on the roadmap for v1.1.
  • Per-tier rate limits — all valid keys get unlimited access for now.
  • Historical data — only currently-cached games (last 24 hours).
  • Polymarket condition_id cross-reference — Polymarket prices flow through as just another book named "Polymarket" or "Polymarket (USA)". A richer merge with polynode’s native PM orderbook data is a planned follow-up.

See also

Unmatched games: when pn_slug is null

Some games don’t have a polynode slug. This happens when upstream team names don’t match any team in polynode’s roster — for example:
  • Esports (cs2, dota2, val, lcs, lpl) — polynode doesn’t carry esports team data, so nothing will match.
  • InitialismsLiga Deportiva Universitaria de Quito vs polynode’s LDU de Quito. Pure name matching can’t bridge the initialism.
  • Transliterations — occasional Cyrillic/Arabic team names that don’t line up.
In production, ~20% of live games are unmatched at any given moment, nearly all esports. For traditional sports (NBA, NFL, MLB, soccer, tennis), the miss rate is under 3%. When pn_slug is null, everything still works — you just use game_id as the identifier instead:
# 1. Search finds unmatched games too (search indexes upstream team names)
curl ".../v1/games/search?q=quito&key=..."

# 2. Fetch by raw game_id instead of slug
curl ".../v1/games/75615-16713-2026-04-14&key=..."

# 3. Markets endpoint accepts game_id exactly like slug
curl ".../v1/games/75615-16713-2026-04-14/markets?book=Polymarket&key=..."
On the WebSocket, unmatched games appear in the snapshot with pn_slug: null, and every subsequent score_change / price_change event on that game carries its game_id. Filter client-side on game_id if you’re tracking a specific unmatched game:
ws.on("message", (raw) => {
  const e = JSON.parse(raw);
  if (e.game_id === "75615-16713-2026-04-14") {
    // handle this game's deltas — slug unnecessary
  }
});
Rule of thumb: game_id is the canonical identifier. pn_slug is a human-friendly alias. Build against game_id and you’ll never hit a dead end.

Rate limits

The REST API enforces 600 requests per minute per API key with a burst allowance of 60 requests. Requests over the limit return 429 Too Many Requests with retry-after: 60. WebSocket connections are not counted — stream consumers don’t need to poll.