ParlayAPI Documentation
Real-time sports odds API: 30+ sources in a single call, 6× cheaper than the-odds-api. Drop-in compatible with TOA's URL surface where it makes sense, with extensions for player props, prediction-market exchanges, and WebSocket streaming.
Quick Start
Three lines to your first call:
pip install parlay-api
from parlay_api import ParlayAPI
client = ParlayAPI(api_key="YOUR_KEY")
odds = client.odds("baseball_mlb", regions="us")curl "https://parlay-api.com/v1/sports/baseball_mlb/odds?regions=us" \ -H "X-API-Key: YOUR_KEY"
const r = await fetch(
"https://parlay-api.com/v1/sports/baseball_mlb/odds?regions=us",
{ headers: { "X-API-Key": "YOUR_KEY" } }
);
const odds = await r.json();import requests
r = requests.get(
"https://parlay-api.com/v1/sports/baseball_mlb/odds",
headers={"X-API-Key": "YOUR_KEY"},
params={"regions": "us"},
)
odds = r.json()Sign up free for a 1,000-credit/month key. No card required.
Authentication
Pass your API key one of two ways:
- Header:
X-API-Key: YOUR_KEY(recommended) - Query param:
?apiKey=YOUR_KEY(TOA-compatible)
WebSocket connections use the query param: wss://parlay-api.com/ws/odds/{sport_key}?apiKey=YOUR_KEY
Credits & Pricing
Most paid endpoints deduct a fixed number of credits per call. Multi-market endpoints such as /odds, /clv/history, and /sgp/price use the formulas in /v1/meta/credit-costs. One /props call returns ALL books for that sport.
| Endpoint | Credits | Notes |
|---|---|---|
/v1/sports | 0 | Free, lists active sport keys |
/v1/sports/{key}/events | 0 | Deduped via canonical_event_id |
/v1/sports/{key}/odds | markets x regions | TOA-shape moneyline/spread/total, floor 1 |
/v1/sports/{key}/props | 3 | All books, all markets, single call |
/v1/sports/{key}/consensus | 3 | Best/worst per (player, market, line) |
/v1/sports/{key}/arbitrage | 10 | Cross-book arb scanner |
/v1/sports/{key}/ev | 10 | +EV picks vs Pinnacle baseline |
/v1/sports/{key}/middles | 3 | Cross-book middles: totals, spreads + player props, with hit/miss economics |
/v1/sports/{key}/live | 3 | In-play games |
/v1/sports/{key}/live/points | 1 | Live PBP snapshot (Free tier OK) |
/v1/sports/{key}/live/sse | 5 / connection | Live PBP stream (Starter+) |
/v1/sports/{key}/live/book_latency | 5 | Per-book lag for arb-mining (Pro+) |
/v1/sports/{key}/live/period_markets | 2 | 1H, Q1-Q4 spreads/totals/h2h (Free OK) |
/v1/inplay/arbs | 5 | Live arb scanner (5s refresh) |
/v1/event-markets/search | 0 beta | Kalshi, Polymarket, and Novig event-market discovery |
/v1/historical/... | varies | See /v1/meta/credit-costs |
/v1/historical/stats | 0 | Public summary, cached 10min |
/v1/stats | 0 | Public |
/ws/odds/{key} | 0 + tier | Business+ tier, no per-frame charge |
Tiers
| Tier | Price | Credits/mo | Concurrent SSE/WS | Best for |
|---|---|---|---|---|
| Free | $0 | 1,000 | 1 (polling only) | Trying it out, light testing |
| Starter | $5 | 20,000 | 3 (PBP live/sse only) | Small scanner, 1-2 sports |
| Pro | $20 | 100,000 | 25 (PBP live/sse only) | Serious bettor, multi-sport |
| Business | $40 | 1,000,000 | 100 (full odds SSE + WS) | Tools, content, reseller |
| Enterprise | $100 | 5,000,000 | 1000 (full odds SSE + WS) | High-volume teams, priority support |
| Scale | $200 | 50,000,000 | 1000 (full odds SSE + WS) | Raw stream access, custom SLA |
Concurrent SSE/WS = max simultaneous push-stream connections per API key. Polling endpoints aren't connection-capped, only credit-capped. Hit the limit and new SSE / WS connections return 429 / WS close 4002 with a clear reason; existing connections aren't affected. The full odds feed (/ws/odds/{key}, /v1/sse/odds/{key}) requires Business tier or above; Starter and Pro only reach the narrower in-play play-by-play stream (/v1/sports/{key}/live/sse, row above) within their connection cap.
Sandbox (no auth, fake data)
Hit /v1/sandbox/sports, /v1/sandbox/sports/{sport_key}/odds, /v1/sandbox/sports/{sport_key}/live/period_markets, or /v1/sandbox/sports/{sport_key}/live/sse to see the response shape with deterministic synthetic data. No API key, no credits consumed, IP rate-limited at 60 req/min. Useful for verifying integration shape during off-hours when no real games are live.
Source health diagnostic
Live-betting bots need to know when a source goes stale so they don't trade on dead data. GET /v1/sports/{sport_key}/live/source-health?apiKey=YOUR_KEY returns per-source freshness for the requested sport (events in the last 5 min, seconds since last event, latest capture timestamp). 1 credit per call. Recommended polling cadence: 30 seconds. A source with seconds_since_last_event > 60 during a known-live game has likely failed; failover yourself or rely on our internal failover (one source going down doesn't break customer SSE, primary auto-promotes).
Postman + OpenAPI spec
Full machine-readable OpenAPI spec at /openapi.json (200+ paths). Postman supports importing OpenAPI directly: Postman → Import → Link → paste the URL above → Import. Auto-generated collection with every endpoint pre-populated.
Official SDKs
Python: pip install parlay-api · source
JavaScript / Node: see the zero-dependency client at /docs/sdks#javascript
The Python SDK ships with built-in math helpers (devig, Kelly sizing, American↔implied conversions) and async iterators for SSE / WebSocket streams.
API stability
Read the full versioning + deprecation policy. Short version: paths under /v1/ are stable. Additive changes ship without notice. Breaking changes ship under /v2/ with 12+ months of overlap. Pricing changes get 30+ days of notice. Your integration won't break overnight.
Every response includes x-requests-used, x-requests-remaining, and x-requests-last headers so you always know how much you've burned. Empty responses still bill normally (no auto-refund).
Python SDK
Pure-Python single-file SDK on PyPI: pip install parlay-api. Source on GitHub.
The SDK is a near drop-in replacement for the-odds-api's official Python clients with extra methods for our extensions and built-in devig math helpers.
from parlay_api import ParlayAPI
client = ParlayAPI(api_key="YOUR_KEY")
# TOA-compatible methods
sports = client.sports()
events = client.events("baseball_mlb")
odds = client.odds("baseball_mlb", regions="us", markets=["h2h", "spreads"])
historical = client.historical_odds("baseball_mlb", date="2024-10-15")
# Extensions
props = client.props("baseball_mlb", markets=["player_total_bases"])
arbs = client.arbitrage("baseball_mlb", limit=20)
consensus = client.consensus("baseball_mlb")
# Devig math (no network call)
fair_over, fair_under = ParlayAPI.devig(over_price=-110, under_price=-110)
edge_pct = ParlayAPI.edge(book_price=-105, fair_prob=fair_over)
# WebSocket URL builder
ws_url = client.websocket_url("baseball_mlb")Sports
List all sport keys with at least one event in the last 24 hours.
Response
[
{
"key": "baseball_mlb",
"group": "Baseball",
"title": "MLB",
"description": "Major League Baseball",
"active": true,
"has_outrights": false
},
...
]See Sport Keys reference for the full list.
Events
List events for a sport. Deduped by canonical_event_id (an MD5 of sport + date + sorted team names) so the same matchup from books with different team naming conventions ("NY Yankees" vs "New York Yankees") collapses into one event.
Parameters
iso (default) or unixResponse
[
{
"id": "8b1f3a2c0e9d4...",
"canonical_event_id": "ee78855a3bdd1019",
"sport_key": "baseball_mlb",
"sport_title": "MLB",
"commence_time": "2026-05-01T19:35:00Z",
"home_team": "New York Yankees",
"away_team": "Kansas City Royals"
},
...
]Odds
TOA-compatible game-line odds. Returns moneyline, spread, totals (and player_* markets if you ask for them) across every book that publishes them.
Parameters
us, us2, uk, eu, au, fr, ca, br, mx, latam, asia. Default: us. Each region maps to an allowlist of books, so a book we serve can still be absent from a region you did not ask for; /regions and GET /v1/meta/regions publish the per-region book lists.h2h (moneyline), spreads, totals, outrights. Player markets accepted too: player_total_bases, etc.draftkings,fanduel,pinnacle. See Bookmaker Keys.american (default) or decimaliso (default) or unixExample
odds = client.odds(
"baseball_mlb",
regions="us",
markets=["h2h", "spreads", "totals"],
bookmakers=["draftkings", "fanduel", "pinnacle"],
)curl "https://parlay-api.com/v1/sports/baseball_mlb/odds?regions=us&markets=h2h,spreads,totals&bookmakers=draftkings,fanduel,pinnacle" \ -H "X-API-Key: YOUR_KEY"
Why a price can look "stuck": stale_seconds vs price_age
These answer two different questions and you need both. stale_seconds is how long since we last READ that book. price_age.unchanged_seconds is how long the NUMBER has been the same. Plenty of books hold a line for hours at a time, so a price that has not moved all afternoon while stale_seconds sits near zero is the book holding its line, not a broken feed.
{
"key": "unibet_fr", "title": "Unibet (FR)",
"last_update": "2026-08-13T13:12:25Z",
"last_update_ms": 1786626745296,
"stale_seconds": 0.06,
"price_age": {
"unchanged_seconds": 14352.44,
"move_observed": false,
"observations": 96
}
}Read that as: we re-read this book 0.06 seconds ago, we have re-read it 96 times, and it has quoted the same number for at least 3 hours 59 minutes. Compare a book that is actively repricing, where stale_seconds is 739.1 and unchanged_seconds is 1645.00 with move_observed: true, meaning we watched it move 27 minutes ago.
| field | type | meaning |
|---|---|---|
unchanged_seconds | float | Seconds this book's full-game lines have been the same number. Always present, never null, never negative, so you can do arithmetic on it without a null check. Greater than or equal to stale_seconds whenever we hold price history for this book on this event, meaning observations is above 0 or move_observed is true. Before that (the first reads after we start tracking a book on an event, and briefly after a collector restart) we have no history to measure from, so observations is 0, move_observed is false and unchanged_seconds is 0.0. Check observations before you rely on the number. |
move_observed | bool | true: we saw the previous price and we saw it change, so unchanged_seconds is exact to within one poll. false: we have not witnessed a move since we started tracking this book on this event, so unchanged_seconds is a LOWER BOUND. Read it as "unchanged for at least that long". |
observations | int | How many times we re-read this book for this event and got the same prices back. This is the proof the feed is working. 0 means either we have no price history for this book on this event yet, or the price moved on our most recent read and the count restarted. move_observed distinguishes the two: false is the first case, true the second. |
last_move_at, last_move_at_ms | string, int | Only with ?include=verification. When we witnessed the move, this is when. Null if and only if move_observed is false, so branch on the bool rather than on the null. We never report a move time we did not observe. |
Scope is per bookmaker per event, across that book's full-game lines (moneyline, spread, total, draw). It is not per market: one move in any of those lines resets the clock, so the field never claims a book is holding when something it offers on that game moved. This is the REST equivalent of price_age_s on the WebSocket and SSE streams.
Two honest limits. We report what we observed, so a book that moved and moved back between two of our polls leaves no trace, and observations is the denominator that lets you judge that (96 checks over 14,352 seconds is a check every 150 seconds). And a price we have never seen move reports a floor rather than a measurement, which is exactly what move_observed: false is telling you. We would rather publish a bound we can stand behind than a number we cannot.
When a market is withheld: data_quality
Occasionally a book publishes two of its own numbers that cannot both be true. The clearest case is a team laying a large spread while that same book's moneyline prices that same team as a heavy underdog. That is one book contradicting itself inside a single event, so no second opinion is needed to know one of the two is wrong. When we see it we withhold the market we cannot stand behind rather than serve it as if it were a real price, and we never substitute an estimated or modelled number in its place.
This check does not depend on what you asked for. If you request only markets=h2h, we still read that book's own handicap for the same event behind the scenes, so the moneyline you get has been checked either way.
The withheld market is simply absent from that bookmaker's markets array, which is a state you already handle: books routinely offer a spread with no moneyline. What is new is that you no longer have to guess why. The affected bookmaker object carries a data_quality block naming exactly what was withheld and on what evidence.
{
"key": "examplebook",
"markets": [ { "key": "spreads", ... }, { "key": "totals", ... } ],
"data_quality": {
"suppressed_markets": ["h2h"],
"reason": "spread_moneyline_contradiction",
"team": "Washington Huskies",
"spread_point": -21.5,
"spread_evidence": "same_block_spread",
"moneyline_price": 11.0,
"moneyline_implied_prob": 0.0909,
"detail": "this book's spread lays 21.5 points on Washington Huskies while its own moneyline prices Washington Huskies at implied 0.091, below an even-money underdog. The two cannot both be right, so the moneyline is withheld and the spread is served unchanged."
}
}
spread_evidence tells you where the contradicting handicap was read from: same_block_spread when it was in the response you asked for, same_book_spread_row when we looked it up for you. Both are the same book on the same event at the same moment.
Read that as: this book still has a line on this game, and you are still getting its spread and its total at full fidelity. Only its moneyline was withheld. The scope is deliberately narrow, one market on one book on one event. Every other book on the event is served untouched, and the same book's other markets are served untouched.
data_quality is additive and only appears when something was actually withheld, so existing clients that iterate markets are unaffected. Do not treat its absence as a quality guarantee: it records the specific contradictions we detect, not every way a price can be wrong. The thresholds are set far outside the range real prices occupy, measured against the whole live board, so a genuine line is not withheld to catch a defective one.
Player Props
Player prop odds across every book in one call. Each row has over_price, under_price, line, and the bookmaker source. Includes the standard sportsbooks plus DFS apps (PrizePicks, Underdog, Betr, Sleeper, Pick6) and exchange data (Novig, Kalshi).
Parameters
player_total_bases,player_hits_runs_rbis. See Market Keys.?player=Judge)midpoint (default, +100/-100 zero-vig) or effective (-137/-137 reflecting actual 2-pick payout)/props serves the latest row per book from the last 60 minutes, so a quiet market can be several minutes old; each row carries age_seconds (real write age) and this filter bounds it.Response shape
[
{
"bookmaker": "draftkings",
"bookmaker_title": "DraftKings",
"player": "Aaron Judge",
"market_key": "player_home_runs",
"market": "Home Runs",
"line": 0.5,
"over_price": 290,
"under_price": -370,
"home_team": "New York Yankees",
"away_team": "Kansas City Royals",
"canonical_event_id": "ee78855a3bdd1019",
"commence_time": "2026-05-01T19:35:00Z",
"last_update": 1746130000000,
"age_seconds": 3
},
...
]Every row carries age_seconds, the real age of that book's latest observation. Props serve the latest row per book from the last 60 minutes, so use ?maxAgeSec=N to bound freshness on quiet markets.
Consensus
For each unique (event, player, market, line), returns the best and worst price across all books, the average consensus price and implied probability (as a percent), and the spread between them. Useful for line-shopping. DFS books are excluded from the math. Moneyline consensus is included — pass markets=h2h for the per-side consensus moneyline of each game (market_key h2h, or h2h_3_way with a Draw side for soccer).
Response per row
{
"canonical_event_id": "ee78855a3bdd1019",
"home_team": "New York Yankees", "away_team": "Kansas City Royals",
"player": "Aaron Judge", "market_key": "player_home_runs", "line": 0.5,
"num_books": 4, "total_books": 4,
"consensus_odds": 290, "consensus_prob": 25.6,
"best_odds": {"bookmaker": "fliff", "price": 310},
"worst_odds": {"bookmaker": "draftkings", "price": 270},
"spread": 40,
"all_books": [
{"bookmaker": "fliff", "price": 310},
{"bookmaker": "fanduel", "price": 295},
...
]
}Arbitrage
Two-leg arbs across books on the same prop, with optimal stake split and projected profit. DFS books excluded. Profit cap 15% (anything higher is almost certainly stale or mis-paired data).
Parameters
+EV Picks
Bets where one book's price implies a higher win probability than a "fair" baseline (Pinnacle de-vigged, with Novig as an exchange-priced cross-check). Returns book, line, edge percentage, and Kelly-optimal stake.
Live Games
Currently in-progress games with grouped book quotes. Sub-10s freshness on our in-play collectors.
Live Point-by-Point (PBP)
Real-time match-state events. Covers tennis, baseball (MLB), basketball (NBA), hockey (NHL), MMA (UFC), boxing, NFL, and soccer (Premier League, La Liga, Bundesliga, Serie A, Ligue 1, UEFA Champions / Europa, MLS). Cross-source redundancy: when our primary feed for a sport drops, a fallback (ESPN / SofaScore) auto-promotes within 30 seconds.
Snapshot (polling)
Returns current state for one match (with match_id) or all in-play matches for the sport (omit match_id). Free tier OK.
Parameters
Stream (Server-Sent Events)
Persistent SSE connection. Server pushes each state change (point won, game won, set closed, goal, foul, pitch outcome, period change) within ~50ms of the event. Starter+ tier required. Use the standard EventSource API.
The connection charge covers the initial snapshot plus streaming. Reconnects are billed as new connections.
// JS
const es = new EventSource('https://parlay-api.com/v1/sports/tennis/live/sse?match_id=...&apiKey=...');
es.addEventListener('initial_state', e => console.log('snap', JSON.parse(e.data)));
es.addEventListener('pbp_event', e => console.log('event', JSON.parse(e.data)));
Cross-book latency
Per-book lag relative to our primary PBP feed. Returns each book's effective latency in seconds. Pro+ tier. Use case: arb scanners check this every few seconds and flag matches where a specific book has stale lines (positive lag > 5s typically means an exploitable window).
{
"sport_key": "baseball_mlb",
"results": [
{"match": "Yankees vs Rangers", "book": "fanduel", "lag_seconds": 7.8, ...},
{"match": "Yankees vs Rangers", "book": "draftkings","lag_seconds": 1.2, ...},
{"match": "Yankees vs Rangers", "book": "caesars", "lag_seconds": 4.0, ...}
]
}
Period markets (1H, Q1-Q4, halves, NHL periods)
In-game spreads, totals, and h2h for sub-game periods: 1st half, 2nd half, quarters (NBA, WNBA, NFL, NCAAF), hockey periods (NHL), or first 5 / first 7 innings (MLB). Includes alternate lines: a single Q1 spread query for one NBA game returns ~8 to 10 alt lines. Open to all tiers (Free can run ~500 test calls; continuous high-frequency polling needs Pro or Business). Latency: 1 to 4 seconds (vs 30s+ on the-odds-api).
Query params: period (FT, 1H, 2H, Q1, Q2, Q3, Q4, OT, P1, P2, P3, F5, F7, or 'all'), match_id, source, market (spread, total, h2h). All optional except apiKey.
GET /v1/sports/basketball_nba/live/period_markets?period=Q1&market=spread&apiKey=...
{
"sport_key": "basketball_nba",
"period": "Q1",
"market": "spread",
"count": 18,
"results": [
{"source":"pinnacle", "home_team":"Oklahoma City Thunder",
"away_team":"Los Angeles Lakers", "period_key":"Q1",
"market":"spread", "side":"home", "line":-3.5, "price":-144,
"age_seconds":1, ...},
{"source":"pinnacle", "side":"away", "line":3.5, "price":120, ...},
...alt lines from -2.5 through -6.0...
]
}
GET /v1/sports/{sport_key}/live/period_markets/sources returns which books have which periods active right now (last 10 min). Useful for client-side discovery.
Sports with period coverage: NBA (1H, 2H, Q1-Q4), WNBA (1H, 2H, Q1-Q4), NCAAB (1H, 2H), NFL (1H, 2H, Q1-Q4), NCAAF (1H, 2H, Q1-Q4), NHL (P1, P2, P3), MLB (F5, F7), soccer leagues (1H, 2H). Coverage depends on book availability per period; e.g. Pinnacle has every period for every sport, DraftKings/FanDuel/BetMGM/Caesars vary by league.
In-Play Arbitrage Scanner
Cross-book arbs detected during live games. Updated every 5 seconds. Pairs with the WebSocket: subscribers receive {"type":"arb_flagged"} frames the moment a new arb is found.
Historical Odds
Historical is split by product shape so modelers can tell prices from results. We do not derive or invent missing odds.
| Product | Endpoint | Use case | Important distinction |
|---|---|---|---|
| Point-in-time odds | /odds | TOA-compatible historical snapshots | Requires date; limited by tier window. |
| Closing odds | /closing-odds | Backtests at final pregame price | Game lines and prop closing rows where real prices exist. |
| Match/results archive | /matches | Schedules, teams, scores, esports results | Rows include has_odds; result-only rows are not price history. |
| Forward line movement | /line-movement | CLV and price-change tracking | Starts when ParlayAPI began capturing that market. |
Esports note: CS2, Dota 2, and Valorant have historical match/result archives plus current forward Pinnacle price capture. They do not yet have deep historical before/during/after odds movement for past years.
Parameters
/matches, return only rows that include real odds.Exchanges
Exchange-specific data including order book depth where available. Currently novig.
Event Market Search
Free-text discovery across Kalshi, Polymarket, and Novig event markets. Built for Specials, next-team markets, coach-out markets, trade deadline markets, and other non-standard contracts that do not fit a fixed sport/event schema.
Try the live demo at /event-markets.
Parameters
AJ Brown next team or Mike Vrabel out before September.kalshi,polymarket,novig. Default checks all three.0.balanced shows a mix of venues. match sorts by match confidence and volume.Example
curl 'https://parlay-api.com/v1/event-markets/search?q=AJ%20Brown%20next%20team&sources=kalshi,novig,polymarket&min_volume=1000'
Response highlights
{
"query": "AJ Brown next team",
"credits_charged": 0,
"source_summary": {
"kalshi": {"count": 10, "max_volume": 283989.29},
"novig": {"count": 4, "max_volume": 9458.94}
},
"markets": [
{
"source": "kalshi",
"event_title": "A.J. Brown's Next Team",
"outcome": "New England",
"prices": {"yes_bid": 0.79, "yes_ask": 0.81}
}
],
"clusters": [
{
"cluster_key": "aj brown next team",
"sources": ["kalshi", "novig"],
"note": "Candidate text match only. Prices remain source-native and are not blended."
}
]
}WebSocket: /ws/odds
Real-time odds streaming for one sport. Business / Enterprise / Scale tier required. Receives a JSON frame the moment a price changes anywhere in our collector pipeline.
Frame types
| type | When | Payload |
|---|---|---|
initial_state | On connect | Last 500 props for the sport |
odds_update | Every change | Array of changed rows |
arb_flagged | New arb detected | The arb opportunity (5s scanner) |
heartbeat | Every 30s | Connection health |
Filter to one game
Send a subscribe frame after connect:
{"type": "subscribe", "event_id": "ee78855a3bdd1019"}To unsubscribe and receive sport-wide updates again:
{"type": "unsubscribe"}Python example
from parlay_api import ParlayAPI
import asyncio, json
import websockets
async def stream():
client = ParlayAPI(api_key="YOUR_KEY")
url = client.websocket_url("baseball_mlb")
async with websockets.connect(url) as ws:
async for raw in ws:
frame = json.loads(raw)
if frame["type"] == "odds_update":
for row in frame["data"]:
print(row["bookmaker"], row["player"],
row["over_price"], row["under_price"])
asyncio.run(stream())End-to-end delivery latency: 300–800 ms on Scale (raw), up to the tier coalesce window otherwise (Business 1 s, Enterprise 0.5 s). Source cadence varies by book and market.
Row freshness: last_update, price_age_s and line_changed_at_ms
Every streamed row carries last_update (epoch milliseconds we last wrote or re-verified that price) and price_age_s, a server-computed convenience field with no client clock-skew guesswork. price_age_s is the seconds since the price last actually MOVED, not since the last write: when a book re-emits an unchanged price as a verification write, last_update advances but price_age_s keeps counting from the real move, so a frozen line never reads as fresh. The row also carries line_changed_at_ms, the epoch milliseconds of that last real move, if you want to compute the age yourself. Sharp books such as Pinnacle update on their own slower cadence, so a larger price_age_s there is real, not stale.
To protect live in-play consumers, a fast book's line that has stayed frozen for more than about 150 seconds during a commenced game is dropped from the live feed rather than streamed with a fresh-looking timestamp, so the socket agrees with /live. Read price_age_s if you want to enforce your own tighter or looser staleness cutoff.
SSE Hot Feed: /v1/sse/hot
EventSource-compatible HTTP stream for enterprise hot paths. It sends a connection frame, source freshness, initial state, then live updates with a 5-second heartbeat.
Filters
| param | example | meaning |
|---|---|---|
bookmakers | fanduel,pinnacle,caesars | Only these books |
kinds | game,prop | Game lines, props, or both |
markets | player_points,player_rebounds | Prop market keys |
event_id | 2026-05-07_Team_A_Team_B | Single event filter |
heartbeat_s | 5 | 1 to 30 seconds |
const es = new EventSource(
"https://parlay-api.com/v1/sse/hot/baseball_mlb?apiKey=YOUR_KEY&bookmakers=fanduel,pinnacle&kinds=game&heartbeat_s=5"
);
es.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "odds_update") console.log(msg.data);
};# Python quickstart
import json, requests
url = "https://parlay-api.com/v1/sse/hot/baseball_mlb"
params = {
"apiKey": "YOUR_KEY",
"bookmakers": "fanduel,pinnacle",
"kinds": "game",
"heartbeat_s": 5,
}
with requests.get(url, params=params, stream=True, timeout=60) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if line and line.startswith("data: "):
msg = json.loads(line[6:])
if msg["type"] in ("hot_feed_status", "odds_update"):
print(msg)Hot feed means fast delivery once a book update lands in our pipeline. It does not invent prices or promise that every external book publishes a new price every 5 seconds.
Operational check: admins run python3 scripts/verify_book_coverage.py before broad outreach or deploys to prove active books survive REST and SSE visibility.
WebSocket: /ws/live
Same protocol as /ws/odds but session-cookie authenticated (used by the live dashboard). For programmatic streaming, use /ws/odds with an API key.
Errors & Status Codes
| Code | Meaning | Action |
|---|---|---|
200 | OK | Use response body |
400 | Invalid sport_key or param | Check spelling and Sport Keys |
401 | Missing or invalid API key | Pass X-API-Key header or ?apiKey= |
403 | Credit limit exceeded | Wait for monthly reset or upgrade tier |
404 | Resource not found | Endpoint or event_id doesn't exist |
422 | Missing required param | Check the param table for that endpoint |
429 | Rate limited | Slow down or retry with backoff |
500 | Server error | Retry. If it persists, email [email protected] |
The Python SDK raises typed exceptions: InvalidAPIKeyError, CreditLimitExceededError, RateLimitedError, TierGatedError, all subclasses of ParlayAPIError.
Sport Keys
Live keys (those with active events in the last 24h). The /v1/sports endpoint returns the current authoritative list.
| Key | Sport |
|---|---|
baseball_mlb | MLB |
basketball_nba | NBA |
basketball_wnba | WNBA |
basketball_ncaab | NCAAB |
icehockey_nhl | NHL |
americanfootball_nfl | NFL |
americanfootball_ncaaf | NCAAF |
mma_mixed_martial_arts | MMA / UFC |
tennis_atp | ATP |
tennis_wta | WTA |
soccer_epl | English Premier League |
soccer_spain_la_liga | La Liga |
soccer_germany_bundesliga | Bundesliga |
soccer_italy_serie_a | Serie A |
soccer_france_ligue_one | Ligue 1 |
soccer_usa_mls | MLS |
golf_pga_championship | PGA Championship |
disc_golf | Disc Golf (accepted, no board currently available) |
esports_lol | League of Legends |
esports_cs2 | Counter-Strike 2 |
esports_valorant | Valorant |
The table above is the headline subset, not the catalogue. GET /v1/sports serves 90+ keys in total, including the regional soccer, basketball, baseball and hockey leagues carried via Pinnacle, and it is the authoritative list and the authoritative count.
How league keys are named
Beyond the marquee leagues above, a key follows the pattern <sport>_<league>: the league's own name, lowercased, with spaces and separators collapsed to underscores. For example, Pinnacle's "Puerto Rico - Superior Nacional" is basketball_puerto_rico_superior_nacional, "Argentina - Torneo Federal" is basketball_argentina_torneo_federal, "Brazil - Paulista FPB U20" is basketball_brazil_paulista_fpb_u20, and "Lebanon - Lebanese Basketball League" is basketball_lebanon_lebanese_basketball_league.
Basketball is covered in full: every league Pinnacle carries is ingested automatically under its own key, from the majors (basketball_nba, basketball_wnba, basketball_ncaab) through the European competitions and every regional, women's and developmental league on offer. A new league appears the moment Pinnacle lists it, with no request or config change on your side.
Smaller leagues rotate in and out of the upstream offer, so a key is live only while that league has active events. GET /v1/sports is the authoritative list of what is live right now, and the umbrella basketball key aggregates every child league in a single call. Any key is queryable at /v1/sports/{sport_key}/odds, /props, /ev, /arbitrage, /consensus and the other per-sport endpoints.
Bookmaker Keys
| Key | Book | Type |
|---|---|---|
draftkings | DraftKings | Sportsbook |
fanduel | FanDuel | Sportsbook |
caesars | Caesars | Sportsbook |
bovada | Bovada | Sportsbook |
betmgm | BetMGM | Sportsbook |
fanatics | Fanatics | Sportsbook |
pinnacle | Pinnacle | Sharp book (de-vig baseline) |
fliff | Fliff | Sportsbook |
bet365 | bet365 | Sportsbook |
betrivers | BetRivers | Sportsbook |
hardrock | Hard Rock | Sportsbook |
parx | Parx | Sportsbook |
betclic | Betclic | Sportsbook (FR) |
pmu | PMU | Sportsbook (FR) |
winamax | Winamax | Sportsbook (FR) |
unibet | Unibet | Sportsbook (EU) |
bwin | Bwin | Sportsbook (EU) |
betrivers_ca | BetRivers (CA) | Sportsbook (CA) |
sportsbet_au | Sportsbet (AU) | Sportsbook (AU) |
rushbet | RushBet | Sportsbook (LATAM) |
novig | Novig | Exchange |
kalshi | Kalshi | Prediction market |
polymarket | Polymarket | Prediction market |
robinhood | Robinhood Event Contracts | Prediction market |
prizepicks | PrizePicks | DFS pick'em |
underdog | Underdog | DFS pick'em |
betr | Betr | DFS pick'em |
sleeper | Sleeper | DFS pick'em |
pick6 | Pick6 (DraftKings) | DFS pick'em |
This table is a subset. GET /v1/bookmakers is the authoritative list and the authoritative count; ?all=true adds merged and decommissioned keys with their status, so you can tell "we never had it" from "it wound down".
Market Keys
The most-used market keys per sport. Hit /v1/sports/{sport_key}/props/markets for the full live list.
MLB
player_total_bases, player_hits, player_home_runs, player_rbis, player_runs, player_singles, player_doubles, player_triples, player_walks, player_strikeouts, player_pitcher_outs, player_hits_allowed, player_earned_runs, player_hits_runs_rbis, player_first_hit, player_first_home_run
NBA / WNBA
player_points, player_rebounds, player_assists, player_threes, player_steals, player_blocks, player_turnovers, player_pra (pts+reb+ast), player_pts_rebs, player_pts_asts, player_rebs_asts, player_double_double, player_triple_double
NHL
player_goals, player_assists, player_points_nhl, player_shots_on_goal, player_saves, player_anytime_goal, player_powerplay_points, player_first_goal_scorer, player_anytime_goal_scorer
NFL
player_pass_yds, player_pass_tds, player_pass_completions, player_rush_yds, player_rec_yds, player_receptions, player_anytime_td, player_first_td, player_longest_rec, player_interceptions
Soccer
player_anytime_goalscorer, player_shots_on_target, player_assists, player_goals_assists, player_fouls, player_total_sets, plus the standard h2h, spreads, totals at the game level.
Migration from the-odds-api
If you're already using TOA, the URL surface for moneyline/spread/total odds is API-compatible. Change the host and you're done:
# Was: TOA_BASE = "https://api.the-odds-api.com/v4" # Now: TOA_BASE = "https://parlay-api.com/v1"
Your existing TOA Python clients pointing at this base URL will work for /sports, /sports/{key}/odds, /sports/{key}/events, and /historical/sports/{key}/odds. Player props are available at /sports/{key}/props with a different (better) shape, see Player Props.
Detailed comparison: parlay-api.com vs the-odds-api