ParlayAPI Documentation

Real-time sports odds API with 30+ sources, player props, prediction-market exchanges, and WebSocket streaming. Some endpoints have similar request patterns to the-odds-api; validate integration behavior, coverage, and current plan costs before switching.

Quick Start

Start with a request below, or use the step-by-step guide for key setup, response checks, and troubleshooting. For complete runnable examples, see the language quickstarts.

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()

You'll need a key for the calls above. The free tier includes 1,000 credits a month, no card required.

Get a free API key

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

Never commit your key. If you accidentally publish one, rotate it from the dashboard immediately.

Reveal and rotate mints the replacement immediately and leaves the previous secret working for a 14-day grace period. Once that period ends, a request still carrying the old secret answers 401 with KEY_ROTATED, and the message names both UTC times: when the key was replaced, and when the old secret stopped. A key that reached an expiry with no replacement answers KEY_EXPIRED, and a revoked or deleted key answers KEY_REVOKED. A stale deployment target is therefore identifiable from the error body alone, without opening a ticket.

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.

EndpointCreditsNotes
/v1/sports0Free, lists active sport keys
/v1/sports/{key}/events0Deduped via canonical_event_id
/v1/sports/{key}/oddsmarkets x regions, or markets x one region-equivalent per ten books when bookmakers= is givenTOA-shape moneyline/spread/total, floor 1
/v1/sports/{key}/props3All books, all markets, single call
/v1/sports/{key}/consensus3Best/worst per (player, market, line)
/v1/sports/{key}/arbitrage10Cross-book arb scanner
/v1/sports/{key}/ev10+EV picks vs Pinnacle baseline
/v1/sports/{key}/middles3Cross-book middles: totals, spreads + player props, with hit/miss economics
/v1/sports/{key}/live3In-play games
/v1/sports/{key}/live/points1Live PBP snapshot (Free tier OK)
/v1/sports/{key}/live/sse5 / connectionLive PBP stream (Starter+)
/v1/sports/{key}/live/book_latency5Per-book lag for arb-mining (Pro+)
/v1/sports/{key}/live/period_markets21H, Q1-Q4 spreads/totals/h2h (Free OK)
/v1/inplay/arbs5Live arb scanner (5s refresh)
/v1/event-markets/search0 betaKalshi, Polymarket, and Novig event-market discovery
/v1/historical/...variesSee /v1/meta/credit-costs
/v1/historical/stats0Public summary, cached 10min
/v1/stats0Public
/ws/odds/{key}0 + tierBusiness+ tier, no per-frame charge

Tiers

TierPriceCredits/moConcurrent SSE/WSBest for
Free$01,0001 (polling only)Trying it out, light testing
StarterSee plans20,0003 (PBP live/sse only)Small scanner, 1-2 sports
ProSee plans100,00025 (PBP live/sse only)Serious bettor, multi-sport
BusinessSee plans1,000,000100 (full odds SSE + WS)Higher-volume API use
EnterpriseSee plans5,000,0001000 (full odds SSE + WS)Higher-volume API use
ScaleSee plans50,000,0001000 (full odds SSE + WS)Higher-volume API use

Concurrent SSE/WS = max simultaneous push-stream connections per API key. Polling endpoints are subject to the plan's rate limits and monthly credit allowance. Hit a stream connection 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 (190+ paths). Postman supports importing OpenAPI directly: Postman → Import → Link → paste the URL above → Import. Auto-generated collection with every endpoint pre-populated.

SDKs and language quickstarts

Python: python -m pip install parlay-api==0.3.2 · verified PyPI release · public source.

JavaScript / Node: start with the native fetch quickstart, which needs no package install. The JavaScript SDK source is public; source availability does not establish an npm release.

All eight language quickstarts include setup, a first request, and empty-response handling. Check the published SDK version's features before relying on retry helpers or streaming support.

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, see the leak fix if curious).

Python SDK

Pure-Python single-file SDK on PyPI: pip install parlay-api. Source on GitHub.

The SDK provides a Python interface for ParlayAPI endpoints, with methods for ParlayAPI extensions and devig helpers. Check its documentation and validate your required endpoints and response handling before migrating an existing client.

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

GET/v1/sports0 credits

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

GET/v1/sports/{sport_key}/events0 credits

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

commenceTimeFromISO 8601 string
Filter to events starting after this time
commenceTimeToISO 8601 string
Filter to events ending before this time
dateFormatenum
iso (default) or unix

Response

[
  {
    "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

GET/v1/sports/{sport_key}/oddsmarkets x regions credits, or markets x bookmakers/10 rounded up when bookmakers= is given

TOA-compatible game-line odds. Returns moneyline, spread, totals (and player_* markets if you ask for them) across every book that publishes them.

You are only charged for markets this endpoint can serve. The price is markets x regions, so a key we cannot serve used to cost the same as one we can and then be dropped from the response. Since 2026-09-05 (ticket #295) such a key is dropped from the multiplier instead: the request is still answered exactly as before (a valid derived market never 400s here, so a migrating pipeline is never aborted mid-run) and the key costs nothing. Every charged response carries x-markets-served, the market keys the charge covered; anything we cannot serve here comes back in x-markets-unservable (billed zero) with the endpoint that owns it in x-markets-served-elsewhere. A servable key that no book is pricing right now still costs a credit and still appears in x-markets-served: that is coverage, not a gap.

bookmakers= replaces the region count in the price. A bookmaker list overrides regions= on this endpoint for the price as well as for the rows you get back. Every ten books you ask for count as one region-equivalent, rounded up and never below one, so ?markets=h2h&bookmakers=<11 books> costs 2 credits and not 1, and a list of ten books or fewer costs the same as a single region. This applies to /v1/sports/{sport_key}/odds only. On /v1/sports/{sport_key}/events/{event_id}/odds a bookmaker list replaces the region filter on the rows in exactly the same way, so you get the books you named rather than the books in the regions you asked for, but the price there stays markets x regions, and /v1/historical/sports/{sport_key}/odds takes no bookmakers= parameter at all. Quote any request against POST /v1/meta/quote before you send it: that preview applies this same rule.

Both sides of a two-way price always come from the same fixture, and from the same phase of it. When the same two teams or players meet twice in a day, each meeting is priced only from what a book posted for that meeting. A side that book has not posted for this fixture comes back with a null price rather than being filled from another match, so a null price is an absent quote and never a value we worked out for you.

Parameters

Repeating a list parameter is the same as the comma form. ?markets=h2h&markets=spreads and ?markets=h2h,spreads are one request and are billed identically: you get the union, not the last occurrence. This holds for markets, bookmakers and regions on every endpoint that takes them, REST, SSE and WebSocket alike. A value repeated across occurrences is counted once, so ?regions=us&regions=us costs one region.

regionscomma-separated
global, 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. Those lists separate the two questions: the active_books field is every book the region carries that has actually written prices in the last 24 hours, and books is the wider set this filter lets through, which includes books that currently return nothing. Every book carries a status (active, paused, retired, not_yet_integrated) and its last write age.

Region filters select book keys. In particular, regions=ca does not establish an Ontario source feed, Ontario-specific prices, licensing, account eligibility or whether an offer can be placed. Per-book activity is separate from market completeness and price parity.
marketscomma-separated
Servable here, and this is the whole list: h2h (moneyline), spreads, totals, alternate_spreads, alternate_totals, outrights, and any player_* / batter_* / pitcher_* / anytime_* / futures_* prop key (e.g. player_total_bases).
Any other catalogued key is accepted and answered, returns no odds here, and costs nothing: it is named in x-markets-unservable and routed by x-markets-served-elsewhere. The period keys h2h_1st_half, spreads_1st_half, totals_1st_half, h2h_1st_quarter, h2h_1st_period, h2h_1st_5_innings, spreads_1st_5_innings and totals_1st_5_innings are served by /v1/sports/{sport_key}/live/period_markets (?period=1H&market=h2h) and /v1/historical/sports/{sport_key}/period_markets. team_totals, btts, correct_score, double_chance, draw_no_bet, the MMA specials and the horse_* racing keys are served by /v1/sports/{sport_key}/props?markets=<key>. Every catalogued key carries a served_by field in GET /v1/markets.
bookmakerscomma-separated
Filter to specific books: draftkings,fanduel,pinnacle. See Bookmaker Keys.
oddsFormatenum
american (default) or decimal
dateFormatenum
iso (default) or unix
eventIdscomma-separated
Limit response to specific event IDs

Example

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"

Every event and row that carries commence_time also carries commence_time_reported. It is true when a source reported the start time you are reading, and false when no source did, in which case commence_time is null. We never fill a missing kickoff with a guess. Events with a null start time are still served, because their prices are real, but they are left out of any window you ask for with commenceTimeFrom, commenceTimeTo, date or live=true, since the question cannot be answered for them. Call the same endpoint without those parameters to see them. Outrights and futures are the exception: markets=outrights events are markets rather than fixtures, they never have a kickoff, and they are returned whether or not you narrow the board. Treat commence_time as nullable in every parser, on /odds, /events, /props, /scores and the historical endpoints alike.

Every bookmaker block carries stale_seconds and last_update_ms, and both describe the OLDEST price in that block, so a fresh price on one side of a market never dates a stale one on the other. A book whose last price for a fixture is older than our 10 minute window is re-served for up to one hour; past that the book is left out of that market instead of being served as current. A block that came from that re-serve is marked "topped_up": true, so you can tell a re-served last price from a fresh write. The mark is on the block, so it covers every market listed under that bookmaker.

Player Props

GET/v1/sports/{sport_key}/props3 credits

Player prop odds from the books available for your query. Broad queries can return a limited board; check the completeness headers below. 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

marketscomma-separated
Filter to specific market keys, e.g. player_total_bases,player_hits_runs_rbis. See Market Keys.
bookmakerscomma-separated
Filter to specific books
playerstring
Partial-match player name (e.g. ?player=Judge)
eventIdstring
Limit to one game
dfsOddsenum
midpoint (default, +100/-100 zero-vig) or effective (-137/-137 reflecting actual 2-pick payout)
limitint 1-10000
Max rows returned, default 5000
offsetint 0-10000
Page offset within the result set, default 0. Counts rows in both flat and grouped mode. Follow x-next-offset while x-result-has-more: true. Past 10000 rows, narrow with markets or bookmakers instead of paging further.
maxAgeSecint
Drop rows whose latest observation is older than N seconds. /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.
groupedbool
Return one entry per prop with a books[] array instead of one row per book. Per-book fields (prices, links, freshness, and the DFS tags below) live inside books[]; limit and offset still count rows in this mode.

Pagination and completeness headers

Successful 200 responses carry pagination and completeness headers; conditional headers are described below. Read them rather than inferring completeness from the number of entries you got back. Error responses have a different body and header contract.

HeaderMeaning
x-result-page-sizeEntries in this response. In grouped=true mode this is props, which is fewer than rows.
x-result-row-countRows behind this page. Equals page size in flat mode; higher in grouped mode.
x-result-limitThe limit this page was served under.
x-result-offsetThe offset this page was served under.
x-result-has-moretrue when more rows exist past this page in the board built for this query. Loop on this value, not the entry count. false does not prove complete sportsbook coverage when x-result-truncated is true.
x-next-offsetThe offset to request next. Present only when x-result-has-more is true.
x-result-truncatedtrue when this response is known to be incomplete for a reason paging cannot fix: an internal per-source cap, a read window that did not reach a busy book's whole board, or a source that failed outright.
x-result-truncated-hintPlain text saying which of those applied and what to do about it. Present whenever x-result-truncated is, plus as a softer note when more pages simply remain.
x-result-degradedComma separated book keys whose own query failed while this board was being built. An initial read failure leaves no rows from that book. A failed deeper read retains earlier rows, but completeness beyond them is unknown. Consensus, best price and EV computed from this response can be incomplete for those books. When the page you asked for falls past the end of such a board the response is 503 with Retry-After instead of an empty 200, and no credits are charged for it.

Fetching a board across books

For cross-book coverage comparisons, start with one request per bookmaker and the market you need, for example ?markets=player_anytime_td&bookmakers=draftkings. Paginate each query using x-next-offset while x-result-has-more: true, and inspect x-result-truncated on every page. A combined query can return fewer rows for a book than a query limited to that book.

x-result-truncated: true together with x-result-has-more: false means you reached the end of a limited board, not the end of all available sportsbook offers. Increasing the offset cannot recover rows omitted from that board. A single-book query can still be truncated; narrow further by market, eventId, or player and check the headers again. Per-book requests reduce the scope but do not guarantee full coverage. Each successful request, including each page, costs 3 credits. Separate requests do not share a fixed snapshot, so a changing board can shift rows between pages.

Degraded empty pages: props_board_degraded

If a requested page would be empty while known source read failures make completeness uncertain, the response is HTTP 503 with JSON error: "props_board_degraded", Retry-After: 5, and x-result-degraded listing the affected book keys. This check also applies after maxAgeSec removes stale rows. No credits are charged for this response.

The JSON object includes detail, dropped_sources for books whose initial read failed, and refill_failed_sources for books whose deeper read failed. Earlier rows from a failed deeper read can remain available on a nonempty 200 page, with degraded/truncation metadata and the ordinary credit charge.

Handle HTTP status before parsing a successful row list. On props_board_degraded, wait at least the Retry-After interval and retry the same offset with a bounded retry policy, or narrow the query. Do not advance the offset or treat this error as the end of the board. The interval is not a promise that the next attempt will succeed. A cap without a known read failure can still produce an empty 200 page with the ordinary charge; check its truncation headers.

Response shape

The body is a JSON list. Read the player's name from player, not player_name. Pagination metadata is in response headers: x-result-limit, x-result-offset, x-result-row-count, x-result-has-more, and, when another page is available, x-next-offset. If x-result-truncated: true, narrow by market or bookmaker; reaching the last page does not prove an untruncated board. Limits and offsets count rows in both flat and grouped mode.

All pages of a props query use the same bounded board-construction window before applying limit and offset. Changing page size therefore does not change source scan allocation or sampling. Source updates, cache refreshes, and rows aging out of maxAgeSec can still change membership between requests; there is no frozen snapshot token. Prefer one large page when the query fits, retain complete selection identities when combining pages, and inspect truncation and degraded headers on every response.

player is a display name and can differ across books, including name suffixes such as Jr. The props response does not currently provide a universal cross-book player ID. canonical_event_id groups event context; it is not an athlete ID. Do not assume that removing a suffix proves two names identify the same athlete. Cross-book player matching requires separate identity verification, plus matching fixture, market, period and line.

Market aliases, offer identity and completeness

The markets= filter accepts known equivalent spellings. For NFL passing yards, player_pass_yds, player_passing_yards, and player_pass_yards belong to the same alias group. The filter matches that group; it does not rewrite every returned market_key to the spelling you requested. A request for player_pass_yds can therefore return player_passing_yards. Keep the returned key, and normalize only documented equivalents when matching your own market taxonomy. Keep period and line separate: a full-game offer and a first-half offer are not the same bet.

For FanDuel, ?includeSids=true adds over_sid and under_sid where retained native metadata validates against the returned offer. These are provider selection identifiers for the corresponding side, not universal market or player IDs. Retain the bookmaker and fixture context, player, market family, period, line and side alongside each ID. Under grouped=true, read the IDs inside the relevant books[] entry. Some observations have no validated native IDs; do not invent one from a market key or assume that a name-based match proves native identity. Selection IDs may change when the provider replaces an offer.

A timestamp is observation metadata, not part of a selection's identity. last_update is the clock retained with that row; it is not a universal sportsbook publication or price-change clock. When present, last_observed with last_update_type: "collector_observation" identifies a validated collector observation. A later observation can carry unchanged lines and prices. Timestamp-only movement does not prove a new offering, a changed price, or current tradability. Repeated observations with the same validated native selection ID and matching offer context can be stored as observations of that selection; retain their clocks rather than treating each timestamp as a new selection.

For a large FanDuel NFL board, narrow one book by market, and further by eventId or player if needed. For example: /v1/sports/americanfootball_nfl/props?bookmakers=fanduel&markets=player_pass_yds&includeSids=true&limit=10000. Follow x-next-offset while x-result-has-more is true and inspect truncation and degraded headers on every page. A scoped query can still be truncated. Non-truncated pages with no continuation and no degraded source indicate that the constructed API board was exhausted without those reported cuts; they do not prove every upstream sportsbook offer was captured.

A market catalogue describes observed markets, not an authoritative inventory of every offer at the sportsbook. Sequential market partitions do not share a frozen snapshot, even when each response is non-truncated. Their combined rows cannot establish a complete sportsbook board at one instant. Retain each request's scope, headers and observation times, and keep completeness unknown if your integration requires that stronger guarantee.

NFL touchdown markets

player_anytime_td represents at least one scored touchdown with line: 0.5. Requests using player_anytime_touchdown_scorer, anytime_td, or anytime_touchdown_scorer are also accepted and return the canonical key. Novig's higher thresholds use player_touchdowns_scored: line 1.5 is 2+ touchdowns and line 2.5 is 3+. Passing touchdowns and combined passing/rushing/receiving totals are separate markets.

Bovada NFL anytime scorer offers for part of a game use separate keys: player_anytime_td_1st_half, player_anytime_td_2nd_half, and player_anytime_td_1st_quarter through player_anytime_td_4th_quarter. The unsuffixed key is full game. Player names no longer carry those period labels or Bovada team tags; name suffixes such as Jr are preserved. Query each segment with its own market key. Live-segment offers marked with labels such as L1H are omitted while their settlement meaning is unverified; they are not returned as full-game or prematch-period offers.

For an anytime offer, over_price is the supplied Yes price and under_price is the supplied No price. A null price means no usable quote was supplied through this feed. It does not prove that the sportsbook never offers that side. Do not calculate a missing No price from the Yes price. Keep the exact player, fixture, period and line when matching offers.

[
  {
    "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.

DFS projection tags: odds_type and projection_type

A DFS row carries a normalized source-declared projection type when the native payload provides enough evidence to classify it. Common values include standard, demon, goblin, boosted, discounted, gimme_pick, stat_slice, and power_up. odds_type holds the lowercase value; projection_type is the same value upper-cased and exists as an additive alias. Underdog reports has_alternates separately because a standard projection can also have an alternate ladder. A row without native type evidence has neither key, so check whether the key is present rather than testing it for a falsy value.

{
  "bookmaker": "prizepicks",
  "player": "Jayden Daniels",
  "market_key": "player_pass_yards",
  "line": 204.5,
  "odds_type": "demon",
  "projection_type": "DEMON"
}

With ?grouped=true these two fields sit inside each books[] entry, beside that book's prices, and are absent from the entry of any book that did not report them. They are not a property of the prop: a group holding a PrizePicks demon alongside a DraftKings price carries no top-level tag at all, because there is no single honest value for one. For one release, a group whose books[] holds exactly one book repeats that book's tag at the top level as a compatibility bridge, since on a single-book group the value is unambiguous. Read the tag off the book you are pricing; the top-level copy is removed in the next release.

The observation pair last_observed and last_update_type follows the same rule. A row bound to a validated live observation carries the instant its response body completed plus last_update_type: "collector_observation"; a row without that evidence carries neither key. Current Underdog and Pick6 rows also expose active native directions and selection IDs. Their payout_multiplier_scope: "entry" identifies pick'em entry payout multipliers, not American side prices. native_identity contains the provider's projection-line identifiers and can change when the provider creates a new line. Under ?grouped=true, these book-specific fields sit inside that book's books[] entry.

Injury object

Rows for a player who has a current ESPN injury record also carry an injury object. Sourced from ESPN's public feed and rebuilt every 10 minutes, covering MLB, NBA, WNBA, NHL, NFL and NCAAF. A player with no record has no injury key, which normally means healthy.

"injury": {
  "status": "Out",
  "description": "Haulcy (ankle) did not participate in Tuesday's practice.",
  "date": "2026-09-01T23:18Z",
  "team": "Indianapolis Colts",
  "team_abbr": "IND",
  "il_category": "O",
  "body_part": "Ankle",
  "side": "Left",
  "position": "S",
  "expected_return": "2026-09-13",
  "updated_at": "2026-09-05T19:47:00Z"
}

description is ESPN's short comment, falling back to the injury detail string, and a detail that is only ESPN's Not Specified placeholder counts as absent. The cache is rebuilt every 10 minutes and its size tracks the injury report, so this is a proportion and not a fixed count: about 97% of records carry a description (830 of 858 on the 2026-09-05 prod cache). The rest have nothing to say and the field is null rather than filled in. The raw detail is still served verbatim by the /injuries endpoints. team and team_abbr are resolved from ESPN's own team id and are null for an id outside the leagues above; no team is ever borrowed from another league. The same record is served in full by /v1/sports/{sport_key}/injuries.

Line Movement

GET/v1/sports/{sport_key}/line-movement2 credits

Time-series price history for one event, grouped into a series per (source, player, market_key, line). Use it for CLV and steam detection.

Parameters

eventId*string
Required. Accepts the canonical_event_id or the event_id from a /props, /odds or /events row. event_id is an accepted alias.
playerstring
Partial-match player name. Strongly recommended: it is what keeps a busy event inside the row cap below.
marketstring
Market key, expanded to its synonym group. market_key is an accepted alias.
bookmakerstring
Single book. source is an accepted alias.
hoursint 1-168
Lookback window, default 24. window_minutes (1-10080) is an accepted alias.

Player-keyed books (PrizePicks, and the teamless tail of every other book)

PrizePicks props are keyed by player, not by fixture: in a one-hour prod sample every PrizePicks row carried no team at all, 34,911 of 34,911. Sleeper, Underdog, Betr, Pinnacle, DraftKings and Caesars each write a smaller teamless tail as well. Those series are resolved by player_name + market_key + sport_key + game_date, the same identity /props publishes them under, so the canonical_event_id you read off a PrizePicks /props row works here directly.

Each series says how it was resolved. matched_by: "team" means the book stated the fixture and home_team/away_team are that fixture. matched_by: "player" means the book stated only the player, and the team fields are empty strings, exactly as /props serves them. A team is never inferred onto a player-keyed row.

Two limits of matching on a player. The identity available on a teamless row is (sport_key, game_date, player_name) and nothing else, so two athletes who share a name on the same slate cannot be separated: their observations arrive as one series. Nothing is fabricated when that happens - the series still carries matched_by: "player" and empty team fields - but it is a grouping, not a stated fixture. And when you ask with a team-keyed eventId, the companion player lane looks up at most the first 300 player names (alphabetically) found in that event's team-keyed rows. Passing player sidesteps both.

Grouping, and books that quote no price

A series is one (source, player, market_key, line). A book that moves its line therefore produces one series per line rather than a single series showing the move; read the moves off the series' time ranges. over_movement describes the price at a fixed line, not the line itself.

PrizePicks and Betr quote no price at all - 0 of 34,982 PrizePicks rows and 0 of 8,657 Betr rows carried an over price in a one-hour prod sample - so their series come back with over_price, under_price, opening_over, current_over and over_movement all null, and everything they moved is in line. Underdog and Sleeper do carry prices. /props normalizes DFS flat payouts to +100/-100 for comparability; this endpoint serves the raw row, so the two differ by design.

Limits, stated plainly

  • Live table only, for every book. This endpoint reads prop_snapshots, the live table, which holds roughly the last 6.5 hours, so hours=168 is accepted and clamped by the data rather than by an error. Resolving a player-keyed eventId searches that same window, so a book that pulled its slate hours ago still resolves. Older prices live in /closing-odds, which is a closing line per market and not a movement series. For PrizePicks and Betr not even that exists: they hold 0 rows in the closing archive (of 40,594,128), so a PrizePicks series older than the live table is not available from any endpoint. Underdog (1,336,439 rows) and Sleeper (46,231) are archived. Measured on prod 2026-09-05.
  • 5,000-row cap per request. The series query returns at most 5,000 rows, most recent first. Every response carries X-Line-Movement-Row-Cap, and X-Line-Movement-Truncated: 1 when the cap actually bound. When it binds, the response covers only the most recent 5,000 rows of your window and opening_over is the oldest price in that slice, not the true open. It binds on the busiest events (275 of 7,072 team-keyed events over a 24-hour lookback in a prod sample); a player or market filter avoids it.
  • Single-snapshot series are dropped. A series needs at least 2 observations to describe movement, so a book that quoted a price once and never moved it does not appear.

Response shape

[
  {
    "event_id": "18c52d6b8b8a0937",
    "home_team": "",
    "away_team": "",
    "matched_by": "player",
    "source": "prizepicks",
    "player": "A.J. Brown",
    "market_key": "player_receiving_yards",
    "line": 62.5,
    "snapshots": [{"timestamp_ms": 1757100000000, "time": "2026-09-05T17:20:00+00:00", "over_price": 100, "under_price": -100, "line": 62.5}, ...],
    "count": 14,
    "opening_over": 100,
    "current_over": 100,
    "over_movement": 0,
    "opening_under": -100,
    "current_under": -100,
    "hours_tracked": 3.4
  },
  ...
]

An empty result is returned as an object, not an array: {movements: [], count: 0, event_id, sport_key, row_cap, min_snapshots_per_series, filters, note}, where note names the reasons it can be empty. That holds on every empty path, including an eventId that resolves to nothing, and it does not change between a cache miss and the cache hit behind it. The two X-Line-Movement-* headers are on empty responses too.

Consensus

GET/v1/sports/{sport_key}/consensus3 credits

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

GET/v1/sports/{sport_key}/arbitrage10 credits

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

limitint
Max arbs returned, default 50
min_profit_pctfloat
Min profit threshold, default 0.5%

+EV Picks

GET/v1/sports/{sport_key}/ev10 credits

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

GET/v1/sports/{sport_key}/live3 credits

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)

GET/v1/sports/{sport_key}/live/points1 credit

Returns current state for one match (with match_id) or all in-play matches for the sport (omit match_id). Free tier OK.

Parameters

match_idstring
Optional. Single match. Omit for all in-play.

Stream (Server-Sent Events)

GET/v1/sports/{sport_key}/live/sse5 credits per connection

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

GET/v1/sports/{sport_key}/live/book_latency5 credits

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)

GET/v1/sports/{sport_key}/live/period_markets2 credits

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

GET/v1/inplay/arbs5 credits

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.

Player Ratings (market implied)

GET/v1/sports/{sport_key}/player-ratings2 credits

A strength rating for each player in a 1v1 sport, derived from the market rather than from results. Supported today for table_tennis and its sub leagues, where we hold paired moneylines from Bovada and Tenbet.

What the number is. For every fixture we take the last price each book posted before the listed start time, remove the vig from the two sides, and fit an iterative Elo to those probabilities. So the rating answers "who did the market think was stronger, before a ball was struck".

What the number is not. It is not an official ITTF or WTT ranking. It is not a results rating and it is not a prediction. Any price quoted at or after the start time is an in play price and is excluded, because an in play price mostly encodes who is currently ahead. rating and implied_win_rate are derived numbers, computed by ParlayAPI from book prices.

Selection rules, all reported back in the response. A fixture is keyed by home player, away player and start time, so the same two players meeting twice in one day counts as two fixtures and neither borrows the other's price. Both sides must have a pregame price or the fixture is dropped. The two implied probabilities must sum to a plausible book total (0.98 to 1.30) or the fixture is dropped. One fixture counts once no matter how many books priced it. A book names the side either on its own ("Adam Svoboda") or inside a compound market label ("Adam Svoboda v Vaclav Dolezal · Match Winner · Adam Svoboda"); both are read, and the trailing name must match a side exactly.

Query params: limit (1 to 500, default 100), min_matches (1 to 50, default 3), window_days (1 to 180, default 30). Coverage is limited by how many pregame prices we hold, so matches_used is normally far smaller than the number of fixtures in the window; the response says exactly how many were dropped and why.

GET /v1/sports/table_tennis/player-ratings?window_days=30&min_matches=3&apiKey=...

{
  "sport_key": "table_tennis",
  "rating_kind": "market_implied",
  "rating_basis": "pregame_closing_lines",
  "rating_method": "iterative_elo_from_devigged_pregame_closing_moneylines",
  "window_days": 30,
  "data_window_start": "2026-08-06T06:00:00Z",
  "data_window_end": "2026-09-05T02:05:00Z",
  "min_matches": 3,
  "total_rated_players": 1036,
  "matches_used": 8750,
  "matches_dropped_inplay_only": 17259,
  "matches_dropped_one_sided": 71,
  "matches_dropped_implied_total_out_of_range": 0,
  "matches_priced_by_multiple_books": 168,
  "implied_total_bounds": [0.98, 1.3],
  "source_matches": 8750,
  "ratings": [
    {"player": "Andrew Baggaley", "rating": 1662.8, "matches": 9,
     "implied_win_rate": 0.7092, "last_match_at": "2026-08-21T14:35:00Z"},
    ...
  ]
}

Field notes: matches_used is the number of fixtures behind the ratings. matches_dropped_inplay_only counts fixtures for which we hold a match winner price named to a side, but every such price was quoted at or after the start; fixtures we hold no named match winner price for are not counted here at all. matches_dropped_one_sided counts fixtures where only one player had a pregame price. data_window_start and data_window_end are the first and last fixture actually used, which can be narrower than window_days. last_match_at is that player's most recent used fixture. source_matches is retained as an alias of matches_used.

Historical Odds

GET/v1/historical/sports/{sport_key}/odds10 x markets x regions credits
GET/v1/historical/sports/{sport_key}/closing-odds10 credits
GET/v1/historical/sports/{sport_key}/matches2 credits
GET/v1/historical/stats0 credits

Historical is split by product shape so modelers can tell prices from results. We do not derive or invent missing odds. Paid self-serve tiers permit internal analysis within one customer entity. Public/customer-facing display, resale or raw-feed/archive redistribution, sublicensing, commercial derived products, and any SLA require a separate signed agreement with express scope. See the terms.

/v1/historical/sports/{sport_key}/odds takes markets= and charges 10 x markets x regions. It serves h2h, spreads and totals and nothing else: the archive it reads has moneyline, spread and total columns, so there is no alt ladder, no outrights and no props in it. Any other key is answered but billed zero and named in x-markets-unservable. Prop history lives at /v1/historical/sports/{sport_key}/closing-odds?markets=<key> and period-market history at /v1/historical/sports/{sport_key}/period_markets; outrights and the alt ladder are live-only, on /v1/sports/{sport_key}/odds.

A retired book (an operator that has closed, currently maverick_games, closed 2026-09-01) is refused on every live endpoint but its closing lines captured while it was open stay queryable here and on the closing-line and bulk export endpoints, and each of its rows carries "retired": true with the closed_on date (the CSV export carries the same two facts as trailing columns). Games dated after the closure are not served for it: the book never closed them.

How far back you can read depends on your plan, not just on credits. Every endpoint in this section is gated by a per-tier window as well as its credit cost. Asking for a date older than your window returns 403 with error: "HISTORICAL_LIMIT" and an allowed_from field naming the oldest date you may read - even when you have your full credit allowance unspent.

PlanHistorical windowOldest readable
Free48 hours2 days back
Starter168 hours7 days back
Pro720 hours30 days back
Business2,160 hours90 days back
Enterprise8,760 hours1 year back
ScaleUp to 87,600 hoursRecords available within that window

The plan window is a maximum lookback, not a promise that records exist throughout it. Historical coverage varies by sport, league, market, bookmaker, and date. Check a representative sample for your exact scope and completeness before planning a backtest or purchase. Every historical response carries x-historical-window-hours and x-historical-window-from headers, so you can read your own limit at runtime instead of inferring it from a 403. If you are backtesting a full season, size your plan by this window first - the credit cost is rarely the binding constraint.

ProductEndpointUse caseImportant distinction
Point-in-time odds/oddsTOA-compatible historical snapshotsRequires date; limited by tier window.
Closing odds/closing-oddsBacktests at final pregame priceGame lines and prop closing rows where real prices exist.
Match/results archive/matchesSchedules, teams, scores, esports resultsRows include has_odds; result-only rows are not price history.
Forward line movement/line-movementCLV and price-change trackingStarts 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

date*YYYY-MM-DD
Required. Date of the games
pricedOnlyboolean
For /matches, return only rows that include real odds.

Exchanges

GET/v1/exchanges0 credits
GET/v1/exchange/{exchange_key}/markets3 credits

Exchange-specific data including order book depth where available. Currently novig.

Event Market Search

GET/v1/event-markets/search0 credits in beta
GET/v1/prediction-markets/search0 credits in beta

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

qstring
Search text, for example AJ Brown next team or Mike Vrabel out before September.
sourcescomma-separated
kalshi,polymarket,novig. Default checks all three.
min_volumenumber
Hide low-volume markets below this source-native volume.
min_confidence0-1
Hide weak text matches. Default 0.
sortenum
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."
    }
  ]
}
Settlement matters. Event-market clusters are discovery leads, not automatic arb proof. Compare the source settlement rules before acting on a cross-venue price gap.

WebSocket: /ws/odds

WSwss://parlay-api.com/v1/ws/odds/{sport_key}?apiKey=YOUR_KEY

Real-time odds streaming for one sport. Business / Enterprise / Scale tier required. Receives eligible stored odds updates after collection, processing and server-side coalescing.

Authentication

Every WebSocket route takes the key in any of these three forms. /ws/odds and /ws/live share one auth path, so a key that opens one opens the other, and both refuse with the same close codes.

FormExampleWhen to use it
X-API-Key headerX-API-Key: YOUR_KEYRecommended. Keeps the key out of URLs and access logs.
?apiKey=wss://parlay-api.com/v1/ws/odds/baseball_mlb?apiKey=YOUR_KEYBrowser WebSocket, which cannot set headers.
?api_key=wss://parlay-api.com/v1/ws/live/tennis_wta?api_key=YOUR_KEYIdentical to ?apiKey=. Both spellings are accepted.

A logged-in dashboard session cookie is a credential on /ws/live only, which is the free live board's own feed. The metered sockets (/ws/odds, /ws/odds-fast) need a key in one of the forms above, whatever browser they are opened from: the credit allowance is metered per key, and a cookie carries none. When both a key and a session cookie are present, the key wins.

Close codes on a refused connect are the same on every WebSocket route: 4003 no key on the request, 1008 key not valid, 4001 tier below Business (or an expired session), 4002 concurrent-connection cap, 4004 monthly credits spent. A refused connect is never billed.

Full WebSocket docs. This section is a quick reference. For the complete protocol, code examples in 10+ languages, +EV alert pattern, player-prop streaming, troubleshooting, and SSE alternative, see /docs/websocket · Quickstart · Examples · Player props · Edge alerts · Troubleshooting · SSE · Migration.

Query params

paramexamplemeaning
bookmakersfanduel,pinnacle,caesarsOnly these books
marketsplayer_points,player_reboundsProp market keys
kindsgame,propGame lines, props, or both
event_idee78855a3bdd1019Single event filter, without the subscribe frame
since1757280000000Resume cursor in epoch ms
difftrueFrames after initial_state carry only changed fields
limit1000Rows in the initial_state frame. 1 to 1000, default 500. Same name, bounds and default as /v1/sse/odds. Any value that is not an integer in range closes the socket 4005, carrying the same sentence the SSE endpoint's 422 gives.
max_age_s600Maximum retained observation age for game lines, including game selections stored as prop rows. Checked on initial snapshots and again before sending updates; 1 to 3600 seconds, default 600. Ordinary player props retain their existing window. A missing observation timestamp is not evidence of freshness.

Complete current game-line baseline

To seed a complete current game-line board, explicitly name the books and request ?kinds=game&markets=h2h,spreads,totals&limit=1000. Accept the frame as a replacement only when it has snapshot_scope="current_game_board", snapshot_complete=true, and truncated=false. It reports resume_mode="replace"; a supplied since is intentionally ignored and reported as since_honored=false.

Without snapshot=complete, other initial frames are bounded recent_rows samples. In that scope, since is a best-effort retained-observation filter, not an exactly-once replay cursor or lossless removal log. diff defaults to false and changes subsequent update frames only. max_age_s defaults to 600 seconds for game lines; it limits observation age but does not make a sample complete or prove that an unchanged price is still offered upstream.

Complete native NFL yards snapshots: WS and SSE

Business tier and above can explicitly request snapshot=complete on /v1/ws/odds/americanfootball_nfl or /v1/sse/odds/americanfootball_nfl, with bookmakers=novig,pick6 (or either source alone), kinds=prop and markets=player_pass_yds,player_receiving_yards. An event filter is optional. Other books, sports, kinds and markets are refused in this mode. Completeness covers requested eligible observed native selections, not every upstream offering or other props streams.

/v1/sse/odds/americanfootball_nfl?bookmakers=novig,pick6&kinds=prop&markets=player_pass_yds,player_receiving_yards&snapshot=complete&limit=500

limit is rows per frame in this mode (1 to 1000, default 500). Configure clients for up to 512 KiB per frame, 64 frames, 16 MiB aggregate, 10,000 matching identities and a 20-second initial transfer. Timeout, unsupported scope, identity or marshal omission, and count or byte limits refuse the complete transfer rather than certify a partial board.

After connected, read initial_state and any snapshot_chunk parts in order. They share snapshot_id and snapshot_generation, with consecutive zero-based snapshot_part, snapshot_parts, snapshot_total_count, count and data. Parts have snapshot_in_progress=true and snapshot_complete=false. No live updates precede baseline completion.

Replace client state atomically only after the terminal snapshot_complete message: same ID and generation, snapshot_part=snapshot_parts, equal matching_count and delivered_count, snapshot_complete=true, snapshot_in_progress=false, unique identities, every part received, and no partial or truncation flags. An empty board still has one zero-row initial part and the terminal. Silence, heartbeat or an interrupted transfer never certifies completion.

A supplied since does not reduce this baseline: resume_mode=replace and since_honored=false. Reconnect performs a fresh complete enumeration. WebSocket subscription replacements start a fresh generation and independent 20-second deadline; retain the last completed baseline until the new terminal and discard older generation chunks. Queued updates follow the baseline; older native observations are rejected while newer observations preserve original quote clocks, literal prices and Pick6 payout metadata. This is not a durable change log. Omit snapshot=complete to retain the default single recent-row initial frame and change-event protocol.

The initial_state frame is filled per book

The row budget set by limit is a fair allocation among the books the fetch returned, not a newest-first fill. Books draw rows in turn until the budget is spent, game lines before props within each book and freshest fixture first. Sharp books such as Pinnacle publish a great many rows on their own cadence; before this, a ten-book subscription could receive 500 rows of one book and meet the other nine only in later odds_update frames.

It is an allocation, not a coverage guarantee. The fill shares out what this connect's fetch came back with, so a book can still be thin or absent from the frame. The per-book numbers below describe this build only: they are what this one snapshot held after its own filters and what it served, and they say nothing about your account, the sport, or your next connect.

The frame says what it contains and what it left out:

fieldmeaning
booksEvery bookmaker key present in data, sorted. This is the universe this frame carries; a book the budget reached no rows for is not in it.
snapshot_fillper_book, the allocation policy above.
truncated_booksPresent only when the budget ran out. Keyed by bookmaker, with the served and omitted row counts for this build. served can be 0, which means this snapshot held rows for that book and the budget reached none of them. Raise limit, or narrow your filters, to see more.
{"type": "initial_state", "sport_key": "americanfootball_nfl",
 "count": 500, "snapshot_limit": 500, "snapshot_fill": "per_book",
 "books": ["betmgm", "bovada", "caesars", "draftkings", "fanduel",
           "novig", "pinnacle", ...],
 "truncated": true,
 "truncated_books": {"pinnacle": {"served": 320, "omitted": 4680}},
 "data": [ ... ]}

A book listed in truncated_books with a non-zero served is in data; one listed with served 0 is not, and does not appear in books either. A book in missing_books is a different case again: that field only appears on a partial frame, where a book's snapshot could not be read at all and the stream was served without it rather than refused.

There is no guarantee of eventual delivery of the omitted rows. An odds_update frame is sent when a row changes, so a line that does not move may never be pushed on this connection. If you need those rows in hand, raise limit, narrow bookmakers / markets / kinds, or inspect the available REST view at /v1/sports/{sport_key}/odds. That REST view is not a complete-baseline continuation protocol for DraftKings or FanDuel.

What the fill does not touch: the merged selector, the native current page and stream lifecycle behaviour are unchanged. Your limit carries into the re-attach, so a connection served a partial frame and re-attached later is filled at the same budget it asked for on connect.

CFB: verification, baseline and withdrawals

For DraftKings and FanDuel pregame game lines, the supported REST request is /v1/sports/americanfootball_ncaaf/odds?bookmakers=draftkings,fanduel&regions=us&markets=h2h,spreads,totals&oddsFormat=american&dateFormat=iso&include=normalized,verification. /v1/odds is not a supported route.

On these bookmaker blocks, verified_at is a source/fixture poll heartbeat. Its key does not bind a market, selection, line or displayed price. is_current reports whether that heartbeat is present and within the five-second threshold at response assembly; it does not certify every outcome. topped_up=true means the view retained older price rows. A fresh heartbeat does not independently reverify those rows or prove that none was suspended or withdrawn. line_changed_at is the retained row timestamp used by this legacy view, not a universal upstream price-change clock.

Stream last_update retains the stored price-row or source timestamp; it is not guaranteed to advance when a poll sees an unchanged price. The frame timestamp is a send clock, and price_age_s tracks retained price-change history. Neither a frame heartbeat nor a bookmaker heartbeat is authoritative per-outcome verification. The requested game-line age bound is rechecked on cached initial rows and before sending updates; dropping an expired row does not emit a source withdrawal.

DraftKings/FanDuel initial frames use snapshot_scope="recent_rows" and snapshot_complete=false. An initial_state frame ends that bounded initial response, not a complete current-board enumeration. There is no supported pagination cursor or end-of-snapshot signal that turns these views into a guaranteed complete current baseline. since filters retained observations; it is not an exactly-once replay cursor.

truncated=false does not override snapshot_complete=false. truncated_books counts only known rows omitted by the final row budget. A fetch cap or another partial read can set truncated=true with snapshot_partial_reasons and no exact omitted counts.

These DraftKings/FanDuel feeds do not provide a guaranteed withdrawal stream or an authoritative complete replacement on reconnect. Missing rows and unchanged prices omitted from a snapshot cannot be classified as withdrawn from absence alone. Apply your own observation-age rejection, but do not treat it as proof of upstream suspension. Integrations requiring verified per-outcome current status, complete replacement and reliable removals cannot obtain those guarantees from this contract.

Frame types

typeWhenPayload
initial_stateOn connectUp to limit rows for the sport, shared across the requested books
odds_updateEvery changeArray of changed rows
arb_flaggedNew arb detectedThe arb opportunity (5s scanner)
heartbeatEvery 30sConnection 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())

WebSocket minimum push intervals are Business 1.0 s, Enterprise 0.5 s and Scale 0.0 s. These are server-side coalescing settings, not book-publication-to-client latency bounds. Source polling, observation age, database-to-client delivery and HTTP request duration measure different stages; see timing and freshness.

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

GEThttps://parlay-api.com/v1/sse/hot/{sport_key}?apiKey=YOUR_KEY

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

paramexamplemeaning
bookmakersfanduel,pinnacle,caesarsOnly these books
kindsgame,propGame lines, props, or both
marketsplayer_points,player_reboundsProp market keys
event_id2026-05-07_Team_A_Team_BSingle event filter
heartbeat_s51 to 30 seconds
limit1000Rows in the initial_state frame. 1 to 1000, default 500. Identical to /ws/odds, and shared across the requested books the same way.
max_age_s600Maximum retained game-line observation age on initial snapshots and before sending updates, including game selections stored as prop rows. 1 to 3600 seconds, default 600; ordinary player props retain their existing window.

The initial_state frame here is the same frame /ws/odds sends, built by the same code and filled per book: see The initial_state frame is filled per book above for books, snapshot_fill and truncated_books. Requesting the same params on either transport returns the same rows.

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

WSwss://parlay-api.com/v1/ws/live/{sport_key}?apiKey=YOUR_KEY

The feed behind our own live board: game lines for one sport, plus the moneylines the board overlays onto each game card. It is a narrower feed than /ws/odds, not a live-only view of it. For spreads, totals, player props and alternate lines, use /ws/odds and read commence_time to pick out the games already under way.

Auth is identical to /ws/odds: the X-API-Key header, ?apiKey=, or ?api_key=, on a Business / Enterprise / Scale key. See Authentication above. The live dashboard opens this same socket with its session cookie, which is why a logged-in browser needs no key; this is the one route where the cookie is a credential.

Three protocol differences from /ws/odds, worth knowing before porting a client:

  • No initial_state frame. You get {"type":"connected", ...} and then odds_update diffs, so inspect GET /v1/sports/{sport_key}/odds or use /ws/odds for a bounded initial view. Neither supplies a guaranteed complete current DraftKings/FanDuel baseline.
  • No query filters. bookmakers, markets, kinds, diff, since, event_id and max_age_s belong to /ws/odds; on this route they are ignored rather than applied.
  • The board payload is a property of the route, not of your credential: a Business key gets the same rows the dashboard cookie gets. Send {"type":"subscribe","event_id":"..."} to lock to one game, and that game arrives in full, which is what the board's game modal uses.

This route used to accept the dashboard cookie and nothing else, so an API key was closed with 4001 "Not logged in" no matter which form it arrived in. Fixed 2026-09-05. If you worked around it by polling, you can go back to the socket.

Errors & Status Codes

CodeMeaningAction
200OKUse response body
400Invalid sport_key or paramCheck spelling and Sport Keys
401Missing or invalid API keyPass X-API-Key header or ?apiKey=
403Credit limit exceededWait for monthly reset or upgrade tier
404Resource not foundEndpoint or event_id doesn't exist
422Missing required paramCheck the param table for that endpoint
429Rate limitedSlow down or retry with backoff
500Server errorRetry. 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.

KeySport
baseball_mlbMLB
basketball_nbaNBA
basketball_wnbaWNBA
basketball_ncaabNCAAB
icehockey_nhlNHL
americanfootball_nflNFL
americanfootball_ncaafNCAAF
mma_mixed_martial_artsMMA / UFC
tennis_atpATP
tennis_wtaWTA
soccer_eplEnglish Premier League
soccer_spain_la_ligaLa Liga
soccer_germany_bundesligaBundesliga
soccer_italy_serie_aSerie A
soccer_france_ligue_oneLigue 1
soccer_usa_mlsMLS
golf_pga_championshipPGA Championship
disc_golfDisc Golf
esports_lolLeague of Legends
esports_cs2Counter-Strike 2
esports_valorantValorant

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

KeyBookType
draftkingsDraftKingsSportsbook
fanduelFanDuelSportsbook
caesarsCaesarsSportsbook
bovadaBovadaSportsbook
betmgmBetMGMSportsbook
fanaticsFanaticsSportsbook
pinnaclePinnacleSharp book (de-vig baseline)
fliffFliffSportsbook
bet365bet365Sportsbook
betriversBetRiversSportsbook
hardrockHard RockSportsbook
parxParxSportsbook
pmuPMUSportsbook (FR)
unibetUnibetSportsbook (EU)
betrivers_caBetRivers (CA)Sportsbook (CA)
sportsbet_auSportsbet (AU)Sportsbook (AU)
rushbetRushBetSportsbook (LATAM)
novigNovigExchange
kalshiKalshiPrediction market
polymarketPolymarketPrediction market
robinhoodRobinhood Event ContractsPrediction market
prizepicksPrizePicksDFS pick'em
underdogUnderdogDFS pick'em
betrBetrDFS pick'em
sleeperSleeperDFS pick'em
pick6Pick6 (DraftKings)DFS pick'em

This table is a subset. GET /v1/bookmakers is the integration registry; ?all=true also includes non-active entries and their status. status: active and endpoints describe configured integrations and request families, not current data availability.

For a props source such as Betr or ParlayPlay, follow its freshness_path to /v1/bookmakers/{key}/freshness. The top-level live_props flag means the latest stored prop observation is at most 300 seconds old. It does not establish priced coverage or delivery for your requested sport and markets. Check the actual /v1/sports/{sport_key}/props?bookmakers={key} response, including row timestamps and source-specific price semantics; neither registry status nor a recent write guarantees a non-empty or complete result.

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.

Period player props

DFS boards publish first-quarter and first-half player lines alongside the full-game line for the same player and stat. They are separate markets and they carry their own market keys, using the same _1st_quarter / _1st_half suffixes as the game-line period markets: player_pass_yds_1st_quarter, player_pass_yds_1st_half, player_rush_yds_1st_quarter, player_rush_yds_1st_half, player_reception_yds_1st_quarter, player_reception_yds_1st_half, player_receptions_1st_quarter, player_receptions_1st_half, player_fantasy_points_1st_quarter, player_fantasy_points_1st_half. GET /v1/markets is the authoritative list.

Every /v1/sports/{sport_key}/props row carries a period field, so you never have to parse the key yourself: "FULL" for a confirmed full-game line, "Q1" for first quarter, "1H" for first half (and "P1" / "F5" where a sport uses them). Legacy unsuffixed PrizePicks rows whose segment was not retained say "UNKNOWN" instead of being guessed as full game. It sits on the prop, not on each book, because the segment is a property of the market rather than of who priced it.

Cutover note. Until this change was deployed, first-quarter and first-half player projections were written under the full-game key, so one player/market/snapshot group could hold several different lines with nothing to tell them apart. Rows written from the deploy onward carry the suffixed key and the correct period. There is no single cutover instant: the PrizePicks collection paths are deployed separately, so each one changes over when it ships. Rows written before a path changed over keep the old mixed key and report "UNKNOWN": the segment was never stored, so it cannot be recovered from the archived row. Until those legacy rows age out, a filter on market_key=player_pass_yds can still include them alongside confirmed full-game lines.

Migration from the-odds-api

If you're already using TOA, some moneyline, spread, and total endpoints have compatible request patterns. A host change alone does not complete a migration: create a ParlayAPI key, map your event IDs, and validate response fields, credit costs, and coverage for the exact sports, markets, books, and dates your application uses.

# Was:
TOA_BASE = "https://api.the-odds-api.com/v4"

# Now:
TOA_BASE = "https://parlay-api.com/v1"

Existing clients may need changes for authentication, identifiers, response handling, or endpoint-specific parameters. Compare the returned events, bookmakers, markets, timestamps, errors, and actual credits charged on a representative sample before switching production traffic. Player props use a different response shape; see Player Props. For customer-facing display, resale or redistribution, sublicensing, commercial derived products, or an SLA, see the terms and contact support about a separate signed agreement with express scope.

Detailed comparison: parlay-api.com vs the-odds-api

Embeddable Odds Widget

Need live odds on a page without writing any code? One script tag renders a compact moneyline table (next games, up to 4 major US books) into any page. Keyless and free; the visible "Live odds by ParlayAPI" footer link is the license. Odds are cached server-side for about a minute per sport.

<div data-parlayapi-widget data-sport="americanfootball_nfl"></div>
<script async src="https://parlay-api.com/widget.js"></script>

Pick a sport and preview it live at parlay-api.com/widget. The backing feed is GET /v1/widget/odds?sport={sport_key} (keyless, h2h only, 60 requests/hour per IP, honest cache_age_seconds in the response). For full data in your own UI, use GET /v1/sports/{sport_key}/odds with an API key.

Ready to make your first call?

Get a free API key in under a minute: 1,000 credits a month, no credit card required.

Get a free API key

Prefer to read more first? Try the answers hub, the cookbook, or the free betting calculators.