# Owls Insight API reference # https://owlsinsight.com | https://owlsinsight.com/docs | Base URL: https://api.owlsinsight.com # Manifest commit: runtime Lines that begin with "|" are generated from the API's own route registrations and access rules and are authoritative; where prose disagrees, the "|" line wins. A {sport} or {book} placeholder accepts exactly the keys listed on its own block; a key that is not listed is not supported there. Tier order: bench < rookie < mvp < hall_of_fame. ## Links - [Docs](https://owlsinsight.com/docs) - [OpenAPI spec](https://owlsinsight.com/openapi.json) - [Pricing](https://owlsinsight.com/pricing) - [Get an API key](https://owlsinsight.com/register?tier=mvp) - [Dashboard keys](https://owlsinsight.com/dashboard/keys) - [MCP](https://owlsinsight.com/mcp) - [Status](https://owlsinsight.com/status) - [Changelog](https://owlsinsight.com/updates) - [Historical data](https://owlsinsight.com/historical-data) - [Historical datasets](https://owlsinsight.com/historical-data/datasets) - [Historical data license](https://owlsinsight.com/historical-data/license) ## Table of contents - [getting-started] Getting started - [authentication] Authentication - [sdk] TypeScript SDK - [python-sdk] Python SDK - [odds-api] Odds - [ev-api] EV / value bets - [source-api] Source API (v2) - [sub-4casters-v2] 4casters (v2) - [sub-888sport-v2] 888sport (v2) - [sub-ballybet-v2] Bally Bet (v2) - [sub-bet365-v2] Bet365 (v2) - [sub-betonline-v2] BetOnline (v2) - [sub-propbuilder-v2] Prop Builder (v2) - [sub-betrivers-v2] BetRivers (v2) - [sub-betus-v2] BetUS (v2) - [sub-bet105-v2] Bet105 (v2) - [sub-bookmaker-v2] Bookmaker.eu (v2) - [sub-bovada-v2] Bovada (v2) - [sub-draftkings-v2] DraftKings (v2) - [sub-pick6-v2] DraftKings Pick6 (v2) - [sub-fanatics-v2] Fanatics Sportsbook (v2) - [sub-fanaticsmarkets-v2] Fanatics Markets (v2) - [sub-fanduel-v2] FanDuel (v2) - [sub-hardrock-v2] Hard Rock Bet (v2) - [sub-kalshi-v2-endpoints] Kalshi (v2) - [sub-lowvig-v2] LowVig (v2) - [sub-mybookie-v2] MyBookie (v2) - [sub-novig-endpoints] Novig (v2) - [sub-pinnacle-v2-endpoints] Pinnacle (v2) - [sub-polymarket-v2-endpoints] Polymarket (v2) - [sub-polymarketus-v2] Polymarket US (v2) - [sub-stake-v2] Stake (v2) - [sub-thescore-v2] theScore (v2) - [sub-thunderpick-v2] Thunderpick (v2) - [sub-tippmixpro-v2] TippmixPro (v2) - [sub-underdog-v2] Underdog (v2) - [sub-versus-v2] Versus (v2) - [deep-links] Deep links - [esports-api] Esports - [props-api] Player props - [sub-sgp] Same Game Parlay - [splits-api] Betting splits - [normalize-api] Team normalization - [scores-api] Live scores - [schedule-api] Schedule and results - [stats-api] Player stats - [prediction-markets] Prediction markets - [sub-prophetx-endpoints] ProphetX - [history-api] Historical data - [websocket] WebSocket - [realtime-api] Real-time odds - [webhooks-api] Webhooks - [rate-limits] Rate limits - [tier-features] Tier features - [coverage] Sportsbooks and coverage - [errors] Errors - [go-sdk] Go SDK - [kotlin-sdk] Kotlin/JVM SDK - [mcp] Use Owls Insight from AI agents (MCP) - [patterns] Common patterns ## [getting-started] Getting started (docs: https://owlsinsight.com/docs#getting-started) #### GET /api/v1/docs/meta | Returns the structured metadata behind the documentation: tier rate limits plus coverage tables, event lists and category lists. | Access: All paying tiers | Response: { success, rateLimits: { tiers: [...] }, ...metadata } #### GET /api/v1/updates | Returns published product updates, newest first. | Access: All paying tiers | Query: limit (optional) - Number of entries to return. Default 50, maximum 200. | Response: { success, data: [{ id, slug, title, category, description, details, published, publishDate }], count } Base URL https://api.owlsinsight.com; every REST call sends Authorization: Bearer YOUR_API_KEY. SDKs: npm install owls-insight-ts, pip install owls-insight, go get github.com/owlsinsight/owls-insight-sdk-go@latest ([go-sdk]), Maven com.owlsinsight:owls-insight for Kotlin and Java ([kotlin-sdk]). ```bash curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.owlsinsight.com/api/v1/nba/odds" ``` Each endpoint block lists the exact sport and book keys it accepts; a sport missing from its Sports line is not served there. Bracketed ids ([props-api]) are the docs anchors (https://owlsinsight.com/docs#props-api). Three pipelines that never mix: (1) v1 consensus, /api/v1/{sport}/odds and odds-update, normalized OddsEvent shape with many books merged per event; (2) real-time Pinnacle, /api/v1/{sport}/realtime and pinnacle-realtime, sub-second push ([realtime-api]); (3) Source API (v2), /api/v2/{book}/{sport} and the per-book v2 events, each book's native data with its full market catalog ([source-api]). Free trial: new accounts get the MVP tier for 3 days, then need a paid plan to keep calling the API. Questions the reference does not answer go to support@owlsinsight.com. ## [authentication] Authentication (docs: https://owlsinsight.com/docs#authentication) REST: Authorization: Bearer YOUR_API_KEY (not x-api-key). 401 for a missing or invalid key; 403 with upgradeUrl, currentTier and requiredTier when the tier lacks the feature. WebSocket: the key goes in the Socket.IO query string, io(url, { query: { apiKey }, transports: ["websocket"] }), not the auth object and not a header. Both SDKs place it from the apiKey constructor option. Keys are managed at https://owlsinsight.com/dashboard. ## [sdk] TypeScript SDK (docs: https://owlsinsight.com/docs#sdk) Package owls-insight-ts, installed with npm install owls-insight-ts: client.rest for REST, client.ws for the WebSocket. Every endpoint and v2 book block names its methods ("SDK: ts ..."); options objects carry the block's query parameters. ```typescript import { OwlsInsight, RateLimitError, OwlsInsightError } from "owls-insight-ts"; const client = new OwlsInsight({ apiKey: process.env.OWLS_INSIGHT_API_KEY! }); const odds = await client.rest.getOdds("nba", { books: ["pinnacle", "fanduel"], alternates: true }); // odds.data keyed by book const cs2Live = await client.rest.getEsportsRealtime("cs2", "BLAST"); // league required const fdEpl = await client.rest.getFanDuelV2("soccer", { league: "english-premier-league" }); const fdMlb = await client.rest.getFanDuelV2("mlb"); // single-league sport, no league await client.ws.connect(); client.ws.subscribe({ sports: ["nba", "nfl"], books: ["pinnacle", "fanduel"] }); // narrows odds-update; replaces the previous subscription client.ws.updateSubscription({ v2: { fanduel: { mlb: "*" } }, esportsRealtime: { cs2: { league: "BLAST" } } }); // merges client.ws.subscribeProps({ sports: ["nba"] }, "fanduel"); // fanduel-props-update; omit the book for Pinnacle (player-props-update) client.ws.on("odds-update", (data) => console.log(Object.keys(data.sports))); const next = await client.ws.waitFor("fanduel-v2-update", 10_000); client.destroy(); try { await client.rest.getOdds("nba"); } catch (err) { if (err instanceof RateLimitError) console.log(err.retryAfterMs, err.remainingMinute, err.remainingMonth, err.code); // code "HISTORY_CONCURRENCY" = the per-key history in-flight cap else if (err instanceof OwlsInsightError) console.log(err.status, err.message); // AuthenticationError 401, ForbiddenError 403, NotFoundError 404, ServiceBusyError 503 extend it } ``` History exports (SDK 0.55.0+): direct calls never retry unless asked (new OwlsInsight({ apiKey, retry: { maxRetries: 3 } }) retries 429 and 503 only, honouring Retry-After); at most historyConcurrency (default 3, the MVP cap) history requests run at once per client; a 503 with Retry-After on a history route throws ServiceBusyError (retryAfterMs), meaning narrow the request or page smaller. ```typescript import { ServiceBusyError } from "owls-insight-ts"; // One event, one page at a time (1000 rows, max 5000), stops on a short page, retries 429/503 three times per page. Never fan one event out across books in parallel. for await (const snap of client.rest.iterHistoryOdds({ eventId: "soccer:Croatia@Panama-20260623", book: "draftkings" }, { pageSize: 2000 })) { console.log(snap.recordedAt, snap.market, snap.side, snap.price, snap.point); // point is a decimal string or null } try { await client.rest.getHistoryOdds("soccer:Croatia@Panama-20260623", { limit: 5000 }); } catch (err) { if (err instanceof ServiceBusyError) console.log("too expensive for one request; narrow or page smaller", err.retryAfterMs); } ``` Graded prop outcomes and hit rates (results and trends endpoints): ```typescript // Graded outcomes for one completed game; ?date= lists a day's graded games first. const day = await client.rest.getPropResults("mlb", { date: "2026-09-01" }); // day.data[].gameId const results = await client.rest.getPropResults("mlb", { game_id: day.data[0].gameId }); for (const e of results.data) { // e.actual is the stat; e.final false = still provisional; e.lines[] = each book's line at game start const dk = e.lines.find((l) => l.book === "draftkings"); if (dk?.verdict) console.log(e.playerName, e.category, e.actual, dk.line, dk.verdict); else if (e.ungradableReason) console.log(e.playerName, e.category, "not graded:", e.ungradableReason); } // Hit rate against a line you supply, or against each game's captured closing line. const t = await client.rest.getPropTrends("mlb", { player: "Shohei Ohtani", category: "hits", line: 0.5, window: 10 }); console.log(t.data.overRate === null ? "nothing resolved" : `${t.data.overs}/${t.data.decided} over`, t.data.streak); const closing = await client.rest.getPropTrends("mlb", { player: "Shohei Ohtani", category: "hits", line: "closing", book: "draftkings" }); // closing.data.games[].closing[] carries every rung the book had at game start; isMain decides the game ``` ## [python-sdk] Python SDK (docs: https://owlsinsight.com/docs#python-sdk) Package owls-insight (Python 3.9+), installed with pip install owls-insight: sync and async clients typed with Pydantic v2 plus a Socket.IO client. Methods are snake_case twins of the TypeScript ones (get_odds, get_fanduel_v2) and every block names them; unknown response fields are kept on model_extra, and methods without a dedicated model return ApiResponse (success / data / meta). ```python from owls_insight import OwlsInsight, AsyncOwlsInsight, RateLimitError, OwlsInsightError client = OwlsInsight(api_key="owlsinsight_...") odds = client.rest.get_odds("nba", books=["pinnacle", "fanduel"]) # data keyed by book -> list of events client.ws.connect() client.ws.subscribe(sports=["nba"], books=["pinnacle"]) client.ws.subscribe_props(book="fanduel") # fanduel-props-update client.ws.update_subscription(prophetx=True) # merge, do not replace client.ws.on("odds-update", lambda data: print(data)) update = client.ws.wait_for("odds-update", timeout=15) client.destroy() async with AsyncOwlsInsight(api_key="owlsinsight_...") as aclient: # same methods, awaited update = await aclient.ws.wait_for("pinnacle-realtime", timeout=30) try: client.rest.get_odds("nba") except RateLimitError as e: print(e.retry_after_ms, e.code) # code "HISTORY_CONCURRENCY" = the per-key history in-flight cap except OwlsInsightError as e: print(e.status, e.message) # AuthenticationError (401), ServiceBusyError (503) extend it ``` History exports (SDK 0.14.0+): direct calls never retry unless asked (OwlsInsight(api_key=..., max_retries=3) retries 429 and 503 only, honouring Retry-After); at most history_concurrency (default 3, the MVP cap) history requests run at once per client, sync and async; a 503 with Retry-After on a history route raises ServiceBusyError (retry_after_ms), meaning narrow the request or page smaller. ```python from owls_insight import ServiceBusyError # One event, one page at a time (1000 rows, max 5000), stops on a short page, retries 429/503 three times per page. Never fan one event out across books in parallel. for snap in client.rest.iter_history_odds("soccer:Croatia@Panama-20260623", book="draftkings", page_size=2000): print(snap["recordedAt"], snap["market"], snap["side"], snap["price"], snap["point"]) # point is a decimal string or None try: client.rest.get_history_odds("soccer:Croatia@Panama-20260623", limit=5000) except ServiceBusyError as e: print("too expensive for one request; narrow or page smaller", e.retry_after_ms) ``` Hard Rock selections carry rootIdx instead of odds; decode offline with root_idx_to_american_odds(72) == 100. Graded prop outcomes and hit rates (results and trends endpoints): ```python day = client.rest.get_prop_results("mlb", date="2026-09-01") # PropResultsDayResponse results = client.rest.get_prop_results("mlb", game_id=day.data[0].gameId) for e in results.data: dk = next((l for l in e.lines if l.book == "draftkings"), None) if dk and dk.verdict: print(e.playerName, e.category, e.actual, dk.line, dk.verdict, "provisional" if not e.final else "final") elif e.ungradableReason: print(e.playerName, e.category, "not graded:", e.ungradableReason) t = client.rest.get_prop_trends("mlb", player="Shohei Ohtani", category="hits", line=0.5, window=10) print("nothing resolved" if t.data.overRate is None else f"{t.data.overs}/{t.data.decided} over", t.data.streak) closing = client.rest.get_prop_trends("mlb", player="Shohei Ohtani", category="hits", line="closing", book="draftkings") # A name that covers two players raises OwlsInsightError (HTTP 409); the body lists matchedNames. ``` ## [odds-api] Odds (docs: https://owlsinsight.com/docs#odds-api) #### GET /api/odds/history | Returns the line movement for one book, market and side of a live or recent event, from the opening line to now. The /api/v1/history endpoints cover archived games. | Access: MVP and above | Query: eventId (required) - Event identifier from the odds board. | Query: book (required) - Sportsbook key. | Query: market (required) - Market to trace. [values: h2h, map_winner, round_handicap, round_totals, spreads, totals, double_chance, btts, asian_handicap, first_half_h2h, first_half_spreads, first_half_totals, corners, cards, corner_spreads, corner_totals, anytime_goalscorer, first_goalscorer, last_goalscorer] | Query: side (required) - Side of the market to trace. [values: home, away, draw, over, under, home_draw, away_draw, home_away, yes, no] | Query: startTime (optional) - Unix timestamp in milliseconds to start the window at. Must not be in the future. Compared with the capture time as stored, which runs 4 to 5 hours behind UTC for many rows stored before 2026-09-16. | Query: hours (optional) - Look back this many hours (1 to 168). Ignored when startTime is set. | Response: { success, data: { eventId, book, market, side, timeRange: { start, end, mode, durationHours }, openingLine: { price, point, timestamp, timestampUtc } | null, history: [{ timestamp, timestampUtc, price, point }], dataPoints, dataQuality } } | Note: With neither startTime nor hours the window starts at the opening line for that book, market and side. | Note: Snapshots whose price could not be stored accurately are excluded; the response says so rather than returning a fabricated window. | Note: point is the stored line and can arrive as a decimal string such as "-1.50". | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | Note: timestamp is the time as stored, US Eastern for most rows stored before 2026-09-16 despite the Z; timestampUtc is the UTC capture time, null where unknown (most rows before 2026-03-28, pinnacle before 2026-04-10). | SDK: ts getOddsHistory() / py get_odds_history() #### GET /api/v1/{sport}/events | Returns the event list for a sport without prices: ids, teams, start time, status and league. Use it to discover eventId values and league labels before calling the priced endpoints. | Access: All paying tiers | Sports: cs2, dota2, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, volleyball | Not available for: cricket, darts, handball, table_tennis, wnba | Query: league (optional) - Restrict events to one league. Matched as a substring of the league label first, then as a slug. meta.available_leagues lists the labels in play. | Response: { success, data: [{ id, sport_key, sport_title, commence_time, home_team, away_team, home_team_id?, away_team_id?, status, eventId, league, country_code }], meta: { sport, sport_key, league, available_leagues, timestamp, event_count, freshness: { ageSeconds, stale } } } | Note: home_team and away_team are one canonical string per team across every book for NFL, college football, NBA, NHL, MLB and college basketball: the name as returned is the join key. eventId is {sport}:{away}@{home}-{YYYYMMDD}, built from those names, and is the key the /api/v1/history endpoints use. Soccer and tennis carry each book's own spelling. | Note: /api/v1/normalize maps an outside name (a score feed, a spreadsheet) onto ours; it is not needed for names we return. | Note: home_team_id and away_team_id (homeTeamId and awayTeamId on /api/v1/history/games) are the stable roster id of the team behind each name, the same from every book and spelling. The key is absent, never null, when a name is not on our roster: every tennis name, a soccer or college name we do not carry, and sports without a roster. There is no abbreviation field. | SDK: ts listEvents() / py list_events() #### GET /api/v1/{sport}/moneyline | Returns the same board as /odds reduced to the moneyline (h2h) market. | Access: All paying tiers | Sports: cs2, dota2, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, volleyball, wnba | Not available for: cricket, darts, handball, table_tennis | Query: books (optional) - Comma-separated sportsbook keys to include; also accepted as book. Defaults to every book in the feed. Unknown keys are ignored; 400 when none of the keys listed under books is valid. Under book a list with no valid key returns every book and is reported in meta.ignored_params. [values: pinnacle, fanduel, draftkings, betmgm, bet365, caesars, novig, 1xbet, betonline, circa, south_point, westgate, wynn, stations, hardrock, betr, bovada] | Query: market (optional) - Narrow the board to one market. /moneyline, /spreads and /totals are the equivalent routes for h2h, spreads and totals, and on those routes the route market applies: a different market here is reported in meta.ignored_params, as is a value outside this list. [values: h2h, map_winner, round_handicap, round_totals, spreads, totals, double_chance, btts, asian_handicap, first_half_h2h, regulation_h2h, regulation_spreads, regulation_totals, first_half_spreads, first_half_totals, corners, cards, corner_spreads, corner_totals, anytime_goalscorer, first_goalscorer, last_goalscorer, total_games, 1st_set_winner, 1st_set_spreads, 1st_set_totals, team_totals, 2nd_set_winner, 3rd_set_winner, 4th_set_winner, 5th_set_winner] | Query: exclude_exchanges (optional) - Set to true to drop exchange-style books from the response. 400 when that leaves no books. | Query: alternates (optional) - Set to true to add Pinnacle alternate rungs. They do NOT arrive as extra outcomes: a spread or total outcome gains an alternateLines array of point and price objects beside its own main point, so code reading outcomes[].point alone sees one number and looks like the flag did nothing. The key is OMITTED, not set to an empty array, on outcomes that have no rungs, so read it as optional. Pinnacle and Novig use this shape (Novig on spreads and totals). Other books publish their ladders as SEPARATE MARKETS instead, which arrive without this flag on any paid tier: DraftKings sends run_line_alternate and total_alternate on mlb, nba and nhl, and no alternates at all on nfl. For the widest ladders use the v2 Source API, which carries each book native board. Needs Rookie or above; on lower tiers the flag is ignored, the standard lines are returned and meta.alternates is false. | Query: state (optional) - Two-letter FanDuel state code whose marketIds to serve on FanDuel outcomes, as on /props: each FanDuel outcome with an id for that state carries it as marketId; selectionId is the same in every state, so build the bet-slip link with the two on that state's FanDuel site. FanDuel deepLink fields are left out of a state response (they point at the New Jersey site). FanDuel issues a different marketId in every state, so a New Jersey marketId fails on another state's slip. Default nj, the feed's own ids and links. meta.fanduelMarketIds reports the state, available (false when the feed holds no ids for that state and sport), how many outcomes were mapped, how many had their marketId omitted, and when the ids were last refreshed. A FanDuel outcome with no id for the state has its marketId omitted rather than served as another state's; other books are unchanged; a value that is not two letters is ignored and named in meta.ignored_params. timestamp is null when available is false. REST only: the odds-update WebSocket event carries the New Jersey ids. | Query: league - as on GET /api/v1/{sport}/events | Response: { success, data: { : [events with prices, each with home_team_id? and away_team_id?] }, meta: { sport, sport_key, market, league, available_leagues, alternates, timestamp, requestedBooks, availableBooks, exchangeBooks, booksReturned, freshness: { ageSeconds, stale }, last_odds_change, fanduelMarketIds?: { state, available, mapped, unmapped, timestamp }, ignored_params?, ignored_reasons? } } | Note: Kalshi and Polymarket are not in this feed; read them from their v2 source endpoints. | Note: meta.freshness.stale turns true once the board is over 90 seconds old. | Note: meta.market is the market the board was narrowed to (all when none), by the route or by the market parameter. | Note: Query parameters this endpoint does not read are reported in meta.ignored_params rather than rejected; a value the endpoint read but could not apply (an unknown market, a comma list where one value is allowed, a book alias with no valid key) is reported too, with the reason in meta.ignored_reasons. | Note: Unknown book keys inside books are dropped and the applied list is meta.requestedBooks. | Note: home_team and away_team are one canonical string per team across every book for NFL, college football, NBA, NHL, MLB and college basketball: the name as returned is the join key. eventId is {sport}:{away}@{home}-{YYYYMMDD}, built from those names, and is the key the /api/v1/history endpoints use. Soccer and tennis carry each book's own spelling. | Note: /api/v1/normalize maps an outside name (a score feed, a spreadsheet) onto ours; it is not needed for names we return. | Note: home_team_id and away_team_id (homeTeamId and awayTeamId on /api/v1/history/games) are the stable roster id of the team behind each name, the same from every book and spelling. The key is absent, never null, when a name is not on our roster: every tennis name, a soccer or college name we do not carry, and sports without a roster. There is no abbreviation field. | SDK: ts getMoneyline() / py get_moneyline() #### GET /api/v1/{sport}/odds | Returns the merged odds board for a sport: every event with its moneyline, spread and total prices at each sportsbook, plus per-response freshness. | Access: All paying tiers | Sports: cs2, dota2, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, volleyball, wnba | Not available for: cricket, darts, handball, table_tennis | Query, response and notes: identical to GET /api/v1/{sport}/moneyline | SDK: ts getOdds() / py get_odds() #### GET /api/v1/{sport}/spreads | Returns the same board as /odds reduced to the point spread market. | Access: All paying tiers | Sports: cs2, dota2, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, volleyball, wnba | Not available for: cricket, darts, handball, table_tennis | Query, response and notes: identical to GET /api/v1/{sport}/moneyline | SDK: ts getSpreads() / py get_spreads() #### GET /api/v1/{sport}/totals | Returns the same board as /odds reduced to the over/under totals market. | Access: All paying tiers | Sports: cs2, dota2, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, volleyball, wnba | Not available for: cricket, darts, handball, table_tennis | Query, response and notes: identical to GET /api/v1/{sport}/moneyline | SDK: ts getTotals() / py get_totals() #### GET /api/v1/1xbet/{sport} | Returns the 1xBet board for the sport on its own endpoint, kept separate from /odds so a delayed 1xBet refresh never affects the main feed. | Access: All paying tiers | Sports: soccer | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, table_tennis, tennis, valorant, volleyball, wnba | Response: { success, book, sport, data: [events], count, meta: { source, fresh, updatedAt, ageMs } } | Note: When no data is held the endpoint still returns 200 with an empty data array, meta.fresh false and a message. | Note: Soccer coverage: before kickoff the board holds every competition 1xBet lists under UEFA (club and national team) and under England, Spain, Italy, Germany, France and Russia, including lower divisions, cups, and women's and youth leagues. Once a match is in play, live matches from every country are included. A domestic league from any other country (the Turkish Super Lig, for example) appears only while its matches are live. There is no league parameter; filter on each event's league field. | SDK: ts getOneXBetSoccer() / py get_one_x_bet_soccer() The consensus view: every book's core markets for every event, team names canonicalized so one game lines up across books. For one book's full native catalog use [source-api]; for sub-second Pinnacle pricing use [realtime-api]. The books filter (also accepted as book) drops unrecognised values and returns 400 only when every value is invalid, so ?books=betr,prophetx returns 200 carrying only betr; test one book at a time when a result looks empty. market narrows the board to one market key (the /moneyline, /spreads and /totals routes are the same thing); a query parameter the route does not read is reported in meta.ignored_params rather than rejected, so check that field whenever a filter appears to have no effect. kalshi and polymarket are accepted but return no bookmakers (v2 only); prophetx is rejected with 400 (own route, [sub-prophetx-endpoints]). Novig carries exchange: true on its bookmaker object; ?exclude_exchanges=true drops every exchange. Event shape inside data[book] (prices are American odds): ```json { "id": "event-uuid", "sport_key": "basketball_nba", "sport_title": "NBA", "commence_time": "2026-03-27T23:10:00Z", "home_team": "Los Angeles Lakers", "away_team": "Boston Celtics", "bookmakers": [ { "key": "pinnacle", "title": "Pinnacle", "last_update": "2026-03-27T23:00:00Z", "markets": [ { "key": "h2h", "outcomes": [ { "name": "Los Angeles Lakers", "price": 150 }, { "name": "Boston Celtics", "price": -175 } ] }, { "key": "spreads", "outcomes": [ { "name": "Los Angeles Lakers", "price": -110, "point": 4.5 }, { "name": "Boston Celtics", "price": -110, "point": -4.5 } ] }, { "key": "totals", "outcomes": [ { "name": "Over", "price": -110, "point": 220.5 }, { "name": "Under", "price": -110, "point": 220.5 } ] } ] } ] } ``` home_team / away_team are plain strings (logos live on scores, schedule and results only). Soccer events carry country_code (ISO 3166-1 alpha-2, null when the league did not match); no endpoint returns flag images. A bookmaker may carry event_link ([deep-links]). ?alternates=true (Rookie and above) adds alternate spreads and totals as an alternateLines array of point and price on the spread or total outcome (Pinnacle and Novig carry them); the array is omitted when a book has none, and they never arrive as extra outcomes. ## [ev-api] EV / value bets (docs: https://owlsinsight.com/docs#ev-api) #### GET /api/v1/{sport}/ev | Returns positive expected value opportunities for a sport: for each event, the sportsbooks offering a moneyline price (or, with market, a spread or total price) that beats our fair price. The fair is Pinnacle's de-vigged line; the other books do not move it, they only block a side when they disagree with it by more than 1.5 points. EV is computed at the fair minus a 1-point safety margin, the same math as the public markets page. | Access: MVP and above | Sports: cs2, dota2, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba | Not available for: cricket, darts, handball, khl, table_tennis, volleyball | Query: min_ev (optional) - Minimum EV percentage to include. Default 0 (every positive-EV price). Clamped to meta.max_ev; anything above that ceiling is treated as a stale-line artifact and dropped. | Query: book (optional) - Restrict opportunities to one sportsbook key (or kalshi / polymarket with venues=true). | Query: venues (optional) - true also lists Kalshi and Polymarket buys, with their trading fee included in the EV. The first request for a sport starts their prices; they appear within about 15 seconds and stay on while you keep asking. [values: true] | Query: market (optional) - h2h (the default, the moneyline), spreads, totals, or spreads,totals. Spreads and totals are scored at Pinnacle's main number, against books that hang that exact number. Supported for nba, ncaab, nfl, ncaaf, nhl, mlb, wnba and soccer. [values: h2h, spreads, totals, spreads,totals] | Response: { success, data: [{ eventId, canonicalEventId, sport, homeTeam, awayTeam, commenceTime, market, fairProbability: { home, away, draw }, booksInConsensus, fair: { method, anchor, sources, reason, home, away, draw }, opportunities: [{ book, market, side, team, bookPrice, bookImplied, fairProbability, fairPrice, evPct, edgePp, kellyFraction, booksInConsensus, kind, fairUnadjusted, roi, fee, tradeable, reason, route, size, link, quoteAgeMs }] }], meta: { sport, market, odds_format, min_ev, max_ev, book, fair_method, pregame_only, events, opportunities, timestamp, freshness: { ageSeconds, stale }, board: { builtAt, ageMs, stale, venues, venueQuotes } } } | Note: fairProbability is the conservative fair: the fair minus 1 point. fairPrice, evPct, edgePp and kellyFraction are all computed at it, so for a sportsbook evPct = (fairProbability x decimal odds - 1) x 100. The fair before that margin is fairUnadjusted on each opportunity and fair.home / fair.away / fair.draw on each event. | Note: Each sportsbook price is scored against a fair that leaves that book's own feed out, so a book never votes on its own price. Sportsbook prices are scored only when Pinnacle prices the game; exchange prices (Novig, and Kalshi and Polymarket with venues=true) can also be scored against the agreement of at least three independent books. booksInConsensus on an opportunity counts the independent sources behind the fair that price was scored against (1 means Pinnacle alone); on an event it counts the sources behind the event's fair. | Note: tradeable is true when the opportunity clears the markets page trade bar (sportsbooks: EV of at least 2%; prediction markets: at least 4%, a bid, a spread of 4 cents or less and at least $50 at the ask); reason says why not. For a prediction market, evPct = (fairProbability - ask - fee) / (ask + fee) x 100. | Note: No opportunity is listed while the odds snapshot behind the answer is over 90 seconds old, or for a game that has started. Within that, a sportsbook price must be under 3 minutes old and an exchange quote under 2 minutes, each by its own timestamp; quoteAgeMs is its age when the answer was served. meta.freshness and meta.board say how old the data is; with no data at all, meta.timestamp is 1970-01-01T00:00:00.000Z and meta.freshness.stale is true. | Note: Pregame only; meta.market and meta.pregame_only state the market and this on every response. | Note: With market=spreads or totals, each event is one game and market: point is the home number (spreads) or the total, and each opportunity carries its own point (the favourite's -3.5, the underdog's +3.5). fairProbability and fair are keyed home/away on spreads and over/under on totals. A book is compared only when it hangs exactly Pinnacle's number; nothing is converted between numbers, and there is no consensus fallback without a current Pinnacle line. venues does not apply: Kalshi and Polymarket are never scored on spreads or totals, and a Novig price is listed with kind venue and tradeable false (its depth is not checked here) and only while its quote is under 2 minutes old. A book whose own two-way price sits more than 10 points from Pinnacle's is treated as a different bet and is not scored. When meta.unsupported is set, nothing is scored for that sport and meta.freshness.stale is true. | Note: On a whole number (pushPossible true) a push refunds the stake. The fair is the chance of each side given no push, so evPct is the EV of a bet that is not refunded: it can overstate the EV by the push chance and never flips its sign. | Note: Tennis, esports and MMA return no spread or total opportunities (meta.unsupported says why): their books do not agree on what a handicap counts, for example sets against games. | Note: Prices are American odds (meta.odds_format). | SDK: ts getEV() / py get_ev() #### GET /api/v1/{sport}/fair | Returns the Owls fair line for a sport: the fair moneyline, spread and total of every upcoming game Pinnacle prices, each side as a probability and as American odds. The fair is Pinnacle's de-vigged line; other books only flag a side when they disagree with it by more than 1.5 points. Use it to price any bet yourself, including games where no book offers positive EV. | Access: MVP and above | Sports: cs2, dota2, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba | Not available for: cricket, darts, handball, khl, table_tennis, volleyball | Response: { success, data: [{ eventId, canonicalEventId, sport, league, homeTeam, awayTeam, commenceTime, moneyline: { method, sources, home: { probability, american, booksDisagree }, away, draw } | null, spread: { point, pushPossible, sources, home: { point, probability, american, booksDisagree }, away } | null, total: { point, pushPossible, sources, over: { point, probability, american, booksDisagree }, under } | null }], meta: { sport, fair_method, anchor, odds_format, pregame_only, markets: { moneyline, spreads, totals }, events, notes, boards: { moneyline: { builtAt, ageMs, stale }, lines: { builtAt, ageMs, stale } | null } } } | Note: These are the fairs before the 1-point safety margin /ev scores against. Each side is the lower of two de-vig models (multiplicative and power), so the sides of one market add up to at most 1, and slightly under whenever the two models differ. | Note: A game with no current fair on any market is left out. A market is null when there is no current fair for it: no Pinnacle line for that market, or its data is too old (meta.boards says which). The moneyline can also come from the agreement of at least three independent books when Pinnacle has no line (method consensus); spreads and totals never do. Sports and games Pinnacle does not price (for example most esports and MMA cards) usually return no fair. | Note: Spreads and totals are priced at Pinnacle's main number. On a whole number (pushPossible true) the fair is the chance of each side given no push. | Note: Spreads and totals are supported for nba, ncaab, nfl, ncaaf, nhl, mlb, wnba and soccer (meta.markets); other sports return the moneyline only. | Note: Pregame only: a game leaves the list when it starts. | SDK: ts getFair() / py get_fair() #### GET /api/v1/{sport}/props/ev | Returns the fair line and positive expected value prices for player props: for each upcoming prop Pinnacle prices both ways, the fair over and under with the vig removed, and every sportsbook price at the same player, stat and line that beats it. The same math as the moneyline /ev: Pinnacle anchors the fair, the other books only flag a side they disagree with by more than 1.5 points, and EV is computed at the fair minus a 1-point margin. | Access: MVP and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, tennis, valorant, volleyball | Query: min_ev (optional) - Minimum EV percentage of an opportunity. Default 0 (every positive-EV price); at most meta.max_ev. | Query: book (optional) - Only opportunities at this sportsbook key. | Query: player (optional) - Only this player: a partial match on the name, as on /props. | Query: category (optional) - Only this prop category (the /props category names and aliases). A category with no priced props returns no rows. | Query: game_id (optional) - Only this game: its gameId, as /props and /props/ev return it. Applied before the row cap, so meta.truncated never hides this game's props behind other games. | Query: all (optional) - true also lists props with no positive-EV price, so every Pinnacle-priced prop comes with its fair line. [values: true] | Response: { success, data: [{ gameId, sport, homeTeam, awayTeam, commenceTime, player, category, line, fair: { method, sources, over: { probability, american, booksDisagree } | null, under }, opportunities: [{ book, kind, side, bookPrice, bookImplied, fairProbability, fairPrice, evPct, edgePp, kellyFraction, booksInConsensus, fairUnadjusted, roi, tradeable, reason, quoteAgeMs }] }], meta: { sport, fair_method, anchor, odds_format, pregame_only, all, min_ev, max_ev, book, game_id, rows, truncated, opportunities, coverage: { games, pinnacleProps, scoredPrices, positive }, timestamp } } | Note: A price is compared only at the same player, the same category and exactly the same line. Only half lines (x.5) are priced: on a whole line, over 20 and 20+ settle differently at exactly 20. Props naming several players are left out. | Note: fairProbability on an opportunity is the conservative fair (the fair minus 1 point), and evPct = (fairProbability x decimal odds - 1) x 100. fair.over and fair.under are the fairs before that margin; each side is the lower of two de-vig models, so they add up to at most 1. | Note: fair.over.booksDisagree / fair.under.booksDisagree count every book; each opportunity is scored against a fair that leaves its own book out, so a price from the one book that disagrees can still be listed (and tradeable). | Note: A one-sided price (an anytime touchdown with no under, for example) is scored on its side. A book whose own two-way price sits more than 10 points from Pinnacle's is treated as a different bet and is never listed. | Note: Novig is listed with kind venue and tradeable false: its depth, bid and spread are not checked on props. Kalshi and Polymarket are not scored here. | Note: Props whose category no mapper could name, and a player key two differently named players share in one game (Luis Garcia and Luis Garcia Jr., for example), are left out. So is a one-sided price whose implied probability sits more than 10 points from Pinnacle's fair on that side. | Note: Pinnacle must have priced the prop within the last 3 minutes, and a sportsbook price must be under 3 minutes old (Novig under 2), each by its own timestamp. A prop Pinnacle has pulled is not listed. Started games are left out. | Note: Supported for nba, ncaab, wnba, nfl, ncaaf, nhl, mlb and soccer. | SDK: ts getPropsEV() / py get_props_ev() Fair = Pinnacle's moneyline with the vig removed (per side, the lower of the multiplicative and power de-vig); the other books do not move it, each independent price source gets one vote and a side is blocked when they disagree with Pinnacle by more than 1.5 points. EV is computed at the conservative fair, the fair minus 1 point, which is what fairProbability holds; fairPrice, evPct, edgePp and kellyFraction (full Kelly, >= 0) are all computed at it, so for a sportsbook evPct = (fairProbability * decimalOdds - 1) * 100. Each sportsbook is scored against a fair that leaves its own feed out, only while its price is under 3 minutes old, and only when Pinnacle prices the game; booksInConsensus on an opportunity counts the sources behind the fair that price was scored against (1 = Pinnacle alone), on an event the sources behind the event's fair. Anything above 25% EV is dropped as a data error, so real value bets are single-digit to low double-digit percent; nothing is listed while the odds snapshot behind the answer is over 90 seconds old, or for a started game. Exchange prices (Novig by default; Kalshi and Polymarket with venues=true) are scored on the ask including any trading fee, evPct = (fairProbability - ask - fee) / (ask + fee) * 100, only while the quote is under 2 minutes old, and without Pinnacle can be scored against at least three agreeing independent books; Kalshi and Polymarket prices appear within about 15 seconds of the first venues=true request for a sport. Each opportunity carries book, side (home | away | draw), team, bookPrice, bookImplied, fairProbability, fairPrice, evPct, edgePp, kellyFraction and booksInConsensus, plus optional kind (book | venue), fairUnadjusted (the fair before the margin), roi, fee, tradeable (clears the markets page trade bar: 2% EV for sportsbooks; 4%, a bid, a spread of 4 cents or less and $50 at the ask for prediction markets), reason, route, size, link and quoteAgeMs (the price's age when served). Each game also carries canonicalEventId and fair { method, anchor, sources, reason, home, away, draw }. meta.fair_method is "fair_v1"; meta.board { builtAt, ageMs, stale, venues, venueQuotes } says how old the data is; with no data at all meta.timestamp is 1970-01-01T00:00:00.000Z and meta.freshness.stale is true. Games without an opportunity are omitted. Spreads and totals: market=spreads, market=totals or market=spreads,totals scores those instead of the moneyline (default h2h), with the same math at Pinnacle's main number. A book is compared only when it hangs exactly that number; nothing is converted between numbers and there is no consensus fallback without a current Pinnacle line. Each event is one game and market with point (the home number or the total) and pushPossible (a whole number: a push refunds the stake, the fair is each side's chance given no push, so evPct is the EV of a bet that is not refunded); fairProbability and fair are keyed home/away on spreads and over/under on totals; each opportunity carries its own point (-3.5 on the favourite, +3.5 on the underdog) and side home | away | over | under. Supported for nba, ncaab, nfl, ncaaf, nhl, mlb, wnba and soccer; tennis, esports and mma return none with meta.unsupported (their books do not agree on what a handicap counts). venues does not apply. Fair line: GET /api/v1/{sport}/fair (MVP and Hall of Fame) returns the fair itself for every upcoming game Pinnacle prices (a game with no fair on any market is left out), whether or not a book beats it: moneyline { method, sources, home, away, draw }, spread { point, pushPossible, sources, home, away } and total { point, pushPossible, sources, over, under }, each side { probability, american, booksDisagree } (spread and total sides also carry their own point). These are the fairs before the 1-point margin; each side is the lower of two de-vig models, so a market's sides add up to at most 1. A market is null when there is no current fair for it (meta.boards says how old each part is); spreads and totals are priced for the sports above (meta.markets). Pregame only. SDK: getFair, getEVLines and getPropsEV need owls-insight-ts 0.80.0 or later. Player props: GET /api/v1/{sport}/props/ev (MVP and Hall of Fame; nba, ncaab, wnba, nfl, ncaaf, nhl, mlb, soccer) applies the same math to every upcoming prop Pinnacle prices both ways: per row gameId, player, category, line, fair { method, sources, over, under } (each { probability, american, booksDisagree }, before the margin) and opportunities (book, kind, side over | under, bookPrice, bookImplied, fairProbability, fairPrice, evPct, edgePp, kellyFraction, booksInConsensus, fairUnadjusted, roi, tradeable, reason, quoteAgeMs). A price is compared only at the same player, category and exactly the same line; only half lines (x.5) are priced and multi-player props are left out; a one-sided price is scored on its side; a book whose own two-way price sits more than 10 points from Pinnacle's is never listed; Novig is kind venue and tradeable false (its depth is not checked on props); Kalshi and Polymarket are not scored; booksDisagree on the fair counts every book while each price is scored against a fair without its own book, so the one dissenting book can still be listed. all=true also lists props with no positive-EV price (the props fair line); player, category, book and min_ev narrow it. ## [source-api] Source API (v2) (docs: https://owlsinsight.com/docs#source-api) #### GET /api/v1/kalshi/{sport}/markets (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/kalshi/{sport}?league= #### GET /api/v1/kalshi/series (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/kalshi/{sport}?league= #### GET /api/v1/kalshi/series/{seriesTicker}/markets (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/kalshi/{sport}?league= #### GET /api/v1/polymarket/{sport}/markets (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/polymarket/{sport}?league= #### GET /api/v1/polymarket/{sport}/realtime (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/polymarket/{sport}?league= #### GET /api/v2/{book}/{sport} | Returns a sportsbook's native markets for a sport exactly as the source publishes them: no mapping, no team normalization, no merging. Each entry in data is one raw market blob keyed by the source's own market id. | Access: All paying tiers | Books: 4casters, 888sport, ballybet, bet105, bet365, betonline, betrivers, betus, bookmaker, bovada, draftkings, fanatics, fanaticsmarkets, fanduel, kalshi, lowvig, mybookie, novig, pick6, pinnacle, polymarket, polymarketus, propbuilder, stake, thescore, thunderpick, tippmixpro, underdog, versus | Query: league (optional) - League key to serve. Required for multi-league sports. | Query: marketIds (optional) - Comma-separated market ids and/or prefixes ending in *, e.g. main or props-*, to return only those entries of data; * returns every entry. Without it, or with it empty, a book returns its default set (DraftKings US sports leave out props-*). | Response: { success, sport, league, marketCount, etag, data: { : }, meta: { source, cached, status, ageSeconds, cacheTimestamp, timestamp, marketIds?, marketsFilteredOut?, defaultExcluded?: { marketIds, count, include }, marketAges?: { : seconds }, truncated?, marketsOmitted?, omittedIds? } } | Note: marketIds narrows data to the listed market ids and prefixes (main,props-* or props-9524; case does not matter). Only those entries are returned, dated in meta.ageSeconds and hashed into the ETag, so a caller of one family keeps its 304s while another family moves. meta.marketIds echoes the filter and meta.marketsFilteredOut counts the entries left out. When the stored board exists but nothing matches, data is empty with meta.cached true and the status and age of the board's primary entries (those outside a family with its own freshness threshold, such as DraftKings game-* and props-*), so it is not mistaken for an outage; if none of those is left (for example only DraftKings game-* and props-* are stored), meta.status is no-data. An entry that is neither an id (letters, digits, - or _) nor a prefix ending in * is a 400. bet365, hardrock, mybookie, stake, thunderpick and underdog ignore marketIds and always return their whole board. | Note: DraftKings nba, nfl, nhl, mlb, ncaab and ncaaf hold main (the league page: game lines), live (the live feed snapshot, when it fits) and one props- entry per player-prop subcategory; nba, nfl and ncaaf also hold one game- entry per game-market subcategory: 1st half, 1st quarter, team totals, for football alternate spread and alternate total, and for the NBA the game leaders (game-16471 Most Points, game-16472 Most Rebounds, game-16473 Most Assists, game-16474 Most Threes, game-16475 Most Points + Rebounds + Assists: one market per game whose selections are the players, as DraftKings prices them). game-* entries are in the default response, refresh about every 30 seconds, are REST only and are judged against their own 90 second threshold. A default request's meta.status is judged by main and live: a game-* entry kept through a failed refresh does not turn it stale (meta.marketAges shows its age), and when main and live are both gone it reads stale. A marketIds request that includes game-* is judged by those entries too. Each props-* entry is DraftKings' response for that subcategory as received (parsed and re-serialized, not mapped), with its events, markets and selections. props-* entries refresh about every 3 to 5 minutes and are REST only (no draftkings-v2-update message). They are left out unless marketIds asks for them; meta.defaultExcluded then gives their count and the marketIds value that includes them. They are judged fresh against their own 480 second threshold, so they do not turn meta.status stale; meta.marketAges gives each returned entry's age in seconds. When one subcategory's request fails, its previous entry is kept for a cycle; if it reaches its 10 minute lifetime first, a request that includes it reads meta.status partial until the entry is cleared, at the latest on the next cycle. | Note: meta.status is ok, stale, partial or no-data. It flips to stale once ageSeconds passes the book's own freshness threshold, which is set to that book's refresh cadence. Entries in a family with its own threshold (DraftKings game-* and props-*) are judged against that threshold instead, and only when the request asked for them. | Note: Send If-None-Match with the ETag response header, quoted exactly as received (a leading W/ is fine), to receive 304 when nothing changed. The body etag is the same value without quotes, so a bare etag never matches. A 304 on /api/v2 does not count toward your monthly quota; it still counts toward your per-minute limit and appears in your usage log. A key that has used up its monthly quota gets 429 even when the request would have been a 304. | Note: meta.cached is false only when there is no stored board to serve (no-data, and partial-miss on the books that report it). It is not a freshness signal: read meta.ageSeconds and meta.status for that. | Note: For a multi-league sport without league, most books return 400 with a hint to the /leagues index. Books flagged mergeWhenUnspecified instead return a merged board with league null, a leagues index attributing every market to its competition, and meta.merged true; if the merge had to truncate, meta.status is partial, meta.truncated says what was omitted, and meta.omittedIds lists the first 100 omitted market ids, to fetch with marketIds (50 per request) or by passing league. | Note: A few books key data by event rather than by market, so one entry holds a whole game. Each book's sport set, single-league sports and league rules are in the v2Books entries of this manifest. | Note: The SDKs expose one method per book: getV2 in TypeScript and get__v2 in Python. | Note: The stake board is the exception to the meta shape above: it serves meta { source, cached, status, timestamp, ageSeconds, hash, now } on every response, with data as an array of fixtures. On no-data and partial-miss, timestamp, ageSeconds and hash are null because there is no board to date, and now is the response clock; never read freshness from a null timestamp. | Note: On stake ice-hockey and basketball, a fixture also carries swish when Stake prices player props for it: Stake's player props (ice hockey: shots on goal, goals, assists, points, hits, plus minus, power play points, blocked shots, and goalie saves once Stake prices them; NBA: points, rebounds, assists, points+assists+rebounds, threes made, double-double, triple-double and first scorer) as { id, status, startTime, players: [{ id, name, position, markets: [{ id, stat: { name }, lines: [{ id, name, line, over, under, balanced, suspended }] }] }] }, over and under in decimal odds. A market lists every line Stake offers: balanced true marks Stake's main line and the others are its alternate lines. Markets with no line are left out, and a game in progress has no swish block because Stake pulls player props once it starts. | Note: propbuilder (Prop Builder) holds one entry per game and market family, family first: game- (the game record with home, away, startTime and entries, the ids stored for that game), gfm--- (one game-market group), ou--- (Over/Under lines for one statistic), ss--- (an N+ milestone ladder), ppd- (Player Doubles) and h2h- (player vs player). marketIds=ou-* returns every Over/Under entry, and game-,ou--*,ss--*,gfm--*,ppd-,h2h- returns one game. gameId is the board's own game id, not the eventId of the /api/v1 endpoints. Odds are decimal, and a market's oddsOverride replaces odds when it is greater than 1. Games are listed until they start, and a market the board suspends or withdraws leaves its entry, as on the board itself. The board sells the game markets (gfm) as parlay legs only, and a leg's price is not the price of a parlay. Several sportsbooks embed this board, and its prices were identical on every site we compared. propbuilder-v2-update pushes changed entries within seconds: load this board on every connect, then apply the messages. | Note: fanatics (Fanatics Sportsbook, not the prediction market) holds one entry per game, keyed by Fanatics' event id: { id, name, startTime, live (true while Fanatics offers in-game betting on it), homeTeam, awayTeam, markets }, teams as { name, abbreviation, nickname }, with every market Fanatics prices for it (moneyline, the full spread and total ladders, team totals, halves and quarters, player milestone props). Each market is { name, bets }, and each bet { id, marketId, name, side, line: { label, value }, status, american, decimal } in Fanatics' own ids: id is the selection id and marketId the market id, so one market of a ladder can hold several Fanatics market ids; side is HOME, AWAY, DRAW, YES or NO where the market has sides; line.label is the line as shown (+3.5, 48, 10+) and line.value its number without the sign (3.5; on correct-score markets read label). Moneyline is two-way in US sports and three-way (with a DRAW side) in soccer; Moneyline 3-Way is the regulation three-way for nba, nhl and ncaaf; Half Time Result is two-way in nfl and ncaaf and three-way in soccer. Only priced selections are listed. A game appears once Fanatics prices it, and while Fanatics prices nothing on it (a suspension) it stays listed with markets [] for up to 30 minutes. Prices are read about every 10 seconds; meta.ageSeconds is the time since the read that last confirmed the game, because the feed carries no change timestamp. nba, nfl, nhl, mlb, ncaaf and ncaab are single-league; soccer is keyed by competition (mls) and merges without league. REST only. | Note: pick6 (DraftKings Pick6) is a pick'em board, not a sportsbook: there are no odds, only More/Less picks on players' stat lines, each side with a multiplier. Entries are Pick6's own responses, verbatim: pickgroups is the sport's pick-group list (pickGroups with each pickGroupId, its start times and the games in gameSet.competitionIds, plus competitions), and cards-{pickGroupId}-{pickCategoryId} is one category's board (Points, Rebounds, Kills, ...). In a cards entry, pickCardByPickableId holds one card per player: entities names the player (dkId, looked up in entityInfoByDkId) and the games (compIds, in competitionById), and activePickableMarkets lists the lines, each with its stat (pickSixMarketId, named in pickSixMarketById), targetValue (the line), isPaused, isLive and activeSelections: statLinePropositionId 1 is More and 2 is Less (names in pickSixMarketById[].propositionNameById), each with its standingsMultiplier. Most lines offer More only: activeSelections lists the sides a line offers. promoPickTypeId is 1 on every line read so far; the other ids are the promotions named in promotionalPickConfigurationsByPromotionTypeId (Gimme Pick, Stat Slice, Power Up). Every sport is single-league; marketIds=cards-* returns every category without the pick-group list, and cards-{pickGroupId}-* one pick group. The board is read about every 60 seconds; meta.ageSeconds is the age of the oldest entry returned, counted from the read that returned it, and meta.status reads stale after 240 seconds. A sport leaves the list when Pick6 offers no picks in it. REST only. | Note: fanaticsmarkets holds one entry per event (the event as Fanatics Markets publishes it, each outcome with its Nadex symbol and probability) and, for every event and market group with a live order book, a books-- entry (group: moneyline, spread, over_and_under and so on). books entries are left out unless marketIds asks for them (books-* for all, or marketIds=*). A books entry is { eventId, group, books: { : { yes: { bids, asks }, no: { bids, asks }, asOf, live } } }, prices 0 to 1 and sizes in contracts. It is rewritten the moment a book moves on Fanatics Markets; when its group has no book left or the event leaves the board, it is deleted. Over WebSocket the books come on their own event, fanaticsmarkets-books-v2-update, only when you subscribe with books: true next to your fanaticsmarkets sports (v2: { fanaticsmarkets: { nfl: '*', books: true } }); fanaticsmarkets-v2-update keeps carrying the events alone. Each message arrives within about a second of the move and carries books entries (kind books), either whole (replace the entry) or with patch true, carrying only the books that changed (merge them by symbol; null means that book is gone); a deleted entry arrives in removedIds, and about once a minute a group carries ids, the full list of that league's books entries (drop any other entry of that league; other leagues are not affected). To sync: subscribe, hold the messages, load the books entries over REST (?marketIds=books-* with league), then apply what you held. For every message, keep per symbol the book with the later asOf (on a tie, take the message); a null removes the symbol. On a patch for an entry you do not hold, and after every reconnect, load it again over REST. Every entry is also sent whole about every 10 minutes, and once more shortly after a restart on our side. asOf is the time that book last CHANGED on Fanatics Markets, not when we read it: a quiet book keeps an old asOf while still current. live true means the book is held on our open subscription; live false means it is the last state we hold and is not confirmed: our subscription dropped or is reconnecting, or an update arrived for it that we could not apply (it turns true again with the next full book). meta.marketAges gives each entry's age: for a books entry, the seconds since we last confirmed it (at most about 30 while every book in it is live; it keeps growing while one is not); for an event, the seconds since its last refresh (a few minutes). The books response is stale once any books entry is 90 seconds old. The orderbook inside an event entry is a copy taken at the event's refresh, with live as of that refresh (false while our subscription is down, or after an update for it that we could not apply: it is then the last book we hold); read the books entries for live depth. #### GET /api/v2/{book}/{sport}/leagues | Returns the league index for a sport at one book: each league key with its name and market count, for use as the league parameter on the markets endpoint. | Access: All paying tiers | Books: 4casters, 888sport, ballybet, bet105, bet365, betonline, betrivers, betus, bookmaker, bovada, draftkings, fanatics, fanaticsmarkets, fanduel, kalshi, lowvig, novig, pick6, pinnacle, polymarket, polymarketus, propbuilder, thescore, tippmixpro, versus | Response: { success, sport, leagueCount, leagues: [{ leagueKey, leagueName, marketCount, marketCountExcludedByDefault?, ... }], meta: { source, status, timestamp } } | Note: Single-league sports still answer, with one entry. | Note: marketCount counts the entries a request without marketIds returns. Books that leave some entries out by default (DraftKings US sports: props-*) also give marketCountExcludedByDefault, the entries reachable only with marketIds. | Note: The SDKs expose one method per book: getV2Leagues in TypeScript and get__v2_leagues in Python. | Source API books (30): 4casters (4casters), 888sport (888sport), ballybet (Bally Bet), bet105 (Bet105), bet365 (Bet365), betonline (BetOnline), betrivers (BetRivers), betus (BetUS), bookmaker (Bookmaker.eu), bovada (Bovada), draftkings (DraftKings), fanatics (Fanatics Sportsbook), fanaticsmarkets (Fanatics Markets), fanduel (FanDuel), hardrock (Hard Rock Bet), kalshi (Kalshi), lowvig (LowVig), mybookie (MyBookie), novig (Novig), pick6 (DraftKings Pick6), pinnacle (Pinnacle), polymarket (Polymarket), polymarketus (Polymarket US), propbuilder (Prop Builder), stake (Stake), thescore (theScore), thunderpick (Thunderpick), tippmixpro (TippmixPro), underdog (Underdog), versus (Versus) #### Source API book: 4casters (4casters) | Routes: GET /api/v2/4casters/{sport}; league index GET /api/v2/4casters/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): cfl, esports, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | WebSocket: 4casters-v2-update, subscribe with v2: { 4casters: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-4casters-v2] #### Source API book: 888sport (888sport) | Routes: GET /api/v2/888sport/{sport}; league index GET /api/v2/888sport/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Multi-league sports (?league= REQUIRED, omitted = 400): soccer, tennis | WebSocket: REST only, no WebSocket event | SDK: ts get888sportV2(), get888sportV2Leagues() / py get_888sport_v2(), get_888sport_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-888sport-v2] #### Source API book: Bally Bet (ballybet) | Routes: GET /api/v2/ballybet/{sport}; league index GET /api/v2/ballybet/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): aussie-rules, boxing, cricket, cycling, darts, formula1, golf, handball, mlb, mma, motorsport, nba, ncaab, ncaaf, nfl, nhl, rugby-league, rugby-union, snooker, soccer, specials, surfing, table-tennis, tennis, wnba | WebSocket: ballybet-v2-update, subscribe with v2: { ballybet: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBallyBetV2(), getBallyBetV2Leagues() / py get_ballybet_v2(), get_ballybet_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-ballybet-v2] #### Source API book: Bet105 (bet105) | Routes: GET /api/v2/bet105/{sport}; league index GET /api/v2/bet105/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): alpine-skiing, archery, athletics, aussie-rules, badminton, bandy, baseball, basketball, basketball-3x3, biathlon, bowls, boxing, cfl, chess, cricket, curling, cycling, darts, esports, fencing, field-hockey, floorball, football, formula1, futsal, gaelic-sports, golf, greyhounds, handball, hockey, horse-racing, hurling, judo, kabaddi, kbo, lacrosse, mlb, mma, motorbikes, motorsport, nascar, nba, ncaab, ncaaf, netball, nfl, nhl, npb, olympics, padel, pool, rugby-league, rugby-union, sailing, ski-jumping, snooker, soccer, softball, speedway, squash, sumo, supercars, surfing, table-tennis, taekwondo, tennis, triathlon, trotting, ultimate, volleyball, water-polo, wnba | WebSocket: bet105-v2-update, subscribe with v2: { bet105: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBet105V2(), getBet105V2Leagues() / py get_bet105_v2(), get_bet105_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-bet105-v2] #### Source API book: Bet365 (bet365) | Routes: GET /api/v2/bet365/{sport}; league index GET /api/v2/bet365/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaaf, nfl, nhl | Multi-league sports (?league= REQUIRED, omitted = 400): soccer | WebSocket: bet365-v2-update, subscribe with v2: { bet365: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBet365V2(), getBet365V2Leagues() / py get_bet365_v2(), get_bet365_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-bet365-v2] #### Source API book: BetOnline (betonline) | Routes: GET /api/v2/betonline/{sport}; league index GET /api/v2/betonline/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): mlb, nba, ncaaf, nfl, nhl, soccer, tennis | WebSocket: betonline-realtime, subscribe with v2: { betonline: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBetOnlineV2(), getBetOnlineV2Leagues() / py get_betonline_v2(), get_betonline_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-betonline-v2] #### Source API book: BetRivers (betrivers) | Routes: GET /api/v2/betrivers/{sport}; league index GET /api/v2/betrivers/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): aussie-rules, boxing, cricket, cycling, darts, formula1, golf, handball, mlb, mma, motorsport, nba, ncaab, ncaaf, nfl, nhl, rugby-league, rugby-union, snooker, soccer, specials, surfing, table-tennis, tennis, wnba | WebSocket: betrivers-v2-update, subscribe with v2: { betrivers: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBetRiversV2(), getBetRiversV2Leagues() / py get_betrivers_v2(), get_betrivers_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-betrivers-v2] #### Source API book: BetUS (betus) | Routes: GET /api/v2/betus/{sport}; league index GET /api/v2/betus/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): alpine-skiing, archery, athletics, aussie-rules, badminton, bandy, baseball, basketball, basketball-3x3, biathlon, bowls, boxing, cfl, chess, cricket, curling, cycling, darts, esports, fencing, field-hockey, floorball, football, formula1, futsal, gaelic-sports, golf, greyhounds, handball, hockey, horse-racing, hurling, judo, kabaddi, kbo, lacrosse, mlb, mma, motorbikes, motorsport, nascar, nba, ncaab, ncaaf, netball, nfl, nhl, npb, olympics, padel, pool, rugby-league, rugby-union, sailing, ski-jumping, snooker, soccer, softball, speedway, squash, sumo, supercars, surfing, table-tennis, taekwondo, tennis, triathlon, trotting, ultimate, volleyball, water-polo, wnba | WebSocket: betus-v2-update, subscribe with v2: { betus: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getBetusV2(), getBetusV2Leagues() / py get_betus_v2(), get_betus_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-betus-v2] #### Source API book: Bookmaker.eu (bookmaker) | Routes: GET /api/v2/bookmaker/{sport}; league index GET /api/v2/bookmaker/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): alternative-sports, aussie-rules, baseball, basketball, boxing, cricket, darts, e-gaming, entertainment, esports, football, futsal, futures, golf, ice-hockey, mma, motorsport, politics, rugby, soccer, surfing, table-tennis, tennis | WebSocket: REST only, no WebSocket event | SDK: ts getBookmakerV2(), getBookmakerV2Leagues() / py get_bookmaker_v2(), get_bookmaker_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-bookmaker-v2] #### Source API book: Bovada (bovada) | Routes: GET /api/v2/bovada/{sport}; league index GET /api/v2/bovada/{sport}/leagues | Sports (all single-league, no ?league= needed): mlb, mma, nba, ncaab, ncaaf, nfl, nhl | WebSocket: REST only, no WebSocket event | SDK: ts getBovadaV2(), getBovadaV2Leagues() / py get_bovada_v2(), get_bovada_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-bovada-v2] #### Source API book: DraftKings (draftkings) | Routes: GET /api/v2/draftkings/{sport}; league index GET /api/v2/draftkings/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Multi-league sports (?league= REQUIRED, omitted = 400): mma, soccer | WebSocket: draftkings-v2-update, subscribe with v2: { draftkings: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getDraftKingsV2(), getDraftKingsV2Leagues() / py get_draftkings_v2(), get_draftkings_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-draftkings-v2] #### Source API book: Fanatics Sportsbook (fanatics) | Routes: GET /api/v2/fanatics/{sport}; league index GET /api/v2/fanatics/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaab, ncaaf, nfl, nhl | Multi-league sports (?league= optional, omitted = every league merged): soccer | WebSocket: REST only, no WebSocket event | SDK: ts getFanaticsV2(), getFanaticsV2Leagues() / py get_fanatics_v2(), get_fanatics_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-fanatics-v2] #### Source API book: Fanatics Markets (fanaticsmarkets) | Routes: GET /api/v2/fanaticsmarkets/{sport}; league index GET /api/v2/fanaticsmarkets/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): boxing, cs2, dota2, golf, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba | WebSocket: fanaticsmarkets-v2-update, subscribe with v2: { fanaticsmarkets: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getFanaticsMarketsV2(), getFanaticsMarketsV2Leagues() / py get_fanaticsmarkets_v2(), get_fanaticsmarkets_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-fanaticsmarkets-v2] #### Source API book: FanDuel (fanduel) | Routes: GET /api/v2/fanduel/{sport}; league index GET /api/v2/fanduel/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaaf, nfl, nhl, wnba | Multi-league sports (?league= REQUIRED, omitted = 400): mma, soccer, tennis | WebSocket: fanduel-v2-update, subscribe with v2: { fanduel: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getFanDuelV2(), getFanDuelV2Leagues() / py get_fanduel_v2(), get_fanduel_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-fanduel-v2] #### Source API book: Hard Rock Bet (hardrock) | Routes: GET /api/v2/hardrock/{state}/{sport}; league index GET /api/v2/hardrock/{state}/{sport}/leagues (path params before {sport}: state) | Single-league sports (no ?league= needed): AMERICAN_FOOTBALL, AUSSIE_RULES, BOXING, CRICKET, DARTS, HANDBALL, MMA, RUGBY, SNOOKER, TABLE_TENNIS, TENNIS, VOLLEYBALL | Multi-league sports (?league= REQUIRED, omitted = 400): BASEBALL, BASKETBALL, ICE_HOCKEY, SOCCER | WebSocket: REST only, no WebSocket event | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-hardrock-v2] #### Source API book: Kalshi (kalshi) | Routes: GET /api/v2/kalshi/{sport}; league index GET /api/v2/kalshi/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): cs2, dota2, lol, mlb, mma, nba, ncaaf, nfl, nhl, overwatch, pubg, rainbow6, rocketleague, soccer, tennis, valorant, wnba | WebSocket: kalshi-v2-update, subscribe with v2: { kalshi: { nba: '*' | [seriesTickers] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getKalshiV2(), getKalshiV2Leagues() / py get_kalshi_v2(), get_kalshi_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-kalshi-v2-endpoints] #### Source API book: LowVig (lowvig) | Routes: GET /api/v2/lowvig/{sport}; league index GET /api/v2/lowvig/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): boxing, cricket, darts, golf, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, table-tennis, tennis, wnba | WebSocket: lowvig-v2-update, subscribe with v2: { lowvig: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getLowvigV2(), getLowvigV2Leagues() / py get_lowvig_v2(), get_lowvig_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-lowvig-v2] #### Source API book: MyBookie (mybookie) | Routes: GET /api/v2/mybookie/{sport}; no /leagues route | Sports (all single-league, no ?league= needed): mlb, nba | WebSocket: mybookie-v2-update, subscribe with v2: { mybookie: { mlb: '*' } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getMyBookieV2() / py get_mybookie_v2() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-mybookie-v2] #### Source API book: Novig (novig) | Routes: GET /api/v2/novig/{sport}; league index GET /api/v2/novig/{sport}/leagues | Sports (all multi-league; ?league= optional, omitted = every league merged into one board): mlb, nba, ncaab, ncaaf, nfl, nhl | WebSocket: REST only, no WebSocket event | SDK: ts getNovigV2(), getNovigV2Leagues() / py get_novig_v2(), get_novig_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-novig-endpoints] #### Source API book: DraftKings Pick6 (pick6) | Routes: GET /api/v2/pick6/{sport}; league index GET /api/v2/pick6/{sport}/leagues | Sports (all single-league, no ?league= needed): cs2, lol, mlb, mma, nascar, nba, ncaab, ncaaf, nfl, nhl, pga, soccer, valorant, wnba | WebSocket: REST only, no WebSocket event | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-pick6-v2] #### Source API book: Pinnacle (pinnacle) | Routes: GET /api/v2/pinnacle/{sport}; league index GET /api/v2/pinnacle/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): esports, handball, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | WebSocket: pinnacle-v2-update, subscribe with v2: { pinnacle: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getPinnacleV2(), getPinnacleV2Leagues() / py get_pinnacle_v2(), get_pinnacle_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-pinnacle-v2-endpoints] #### Source API book: Polymarket (polymarket) | Routes: GET /api/v2/polymarket/{sport}; league index GET /api/v2/polymarket/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): cs2, dota2, lol, mlb, mma, nba, ncaaf, nfl, nhl, overwatch, pubg, rainbow6, rocketleague, soccer, tennis, valorant, wnba | WebSocket: polymarket-v2-update, subscribe with v2: { polymarket: { nba: '*' | [tagSlugs] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getPolymarketV2(), getPolymarketV2Leagues() / py get_polymarket_v2(), get_polymarket_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-polymarket-v2-endpoints] #### Source API book: Polymarket US (polymarketus) | Routes: GET /api/v2/polymarketus/{sport}; league index GET /api/v2/polymarketus/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): boxing, cs2, dota2, golf, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, overwatch, rainbow6, rocketleague, soccer, tennis, valorant, wnba | WebSocket: REST only, no WebSocket event | SDK: ts getPolymarketUsV2(), getPolymarketUsV2Leagues() / py get_polymarketus_v2(), get_polymarketus_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-polymarketus-v2] #### Source API book: Prop Builder (propbuilder) | Routes: GET /api/v2/propbuilder/{sport}; league index GET /api/v2/propbuilder/{sport}/leagues | Sports (all single-league, no ?league= needed): mlb, nba, ncaaf, nfl, nhl, wnba | WebSocket: propbuilder-v2-update, subscribe with v2: { propbuilder: { nfl: '*' } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getPropbuilderV2(), getPropbuilderV2Leagues() / py get_propbuilder_v2(), get_propbuilder_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-propbuilder-v2] #### Source API book: Stake (stake) | Routes: GET /api/v2/stake/{sport}; no /leagues route | Sports (all single-league, no ?league= needed): baseball, basketball, cs2, ice-hockey, mma, soccer, table-tennis, tennis | WebSocket: REST only, no WebSocket event | SDK: ts getStakeV2() / py get_stake_v2() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-stake-v2] #### Source API book: theScore (thescore) | Routes: GET /api/v2/thescore/{sport}; league index GET /api/v2/thescore/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaaf, nfl, nhl | Multi-league sports (?league= REQUIRED, omitted = 400): soccer, tennis | WebSocket: REST only, no WebSocket event | SDK: ts getTheScoreV2(), getTheScoreV2Leagues() / py get_thescore_v2(), get_thescore_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-thescore-v2] #### Source API book: Thunderpick (thunderpick) | Routes: GET /api/v2/thunderpick/{sport}; no /leagues route | Sports (all single-league, no ?league= needed): american-football, baseball, basketball, cs2, dota2, lol, soccer, tennis, valorant | WebSocket: thunderpick-v2-update, subscribe with v2: { thunderpick: { baseball: '*' } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getThunderpickV2() / py get_thunderpick_v2() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-thunderpick-v2] #### Source API book: TippmixPro (tippmixpro) | Routes: GET /api/v2/tippmixpro/{sport}; league index GET /api/v2/tippmixpro/{sport}/leagues | Single-league sports (no ?league= needed): mlb, nba, ncaab, ncaaf, nfl, nhl | Multi-league sports (?league= REQUIRED, omitted = 400): boxing, cs2, dota2, lol, mma, soccer, tennis, valorant | WebSocket: tippmixpro-v2-update, subscribe with v2: { tippmixpro: { : '*' | [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getTippmixproV2(), getTippmixproV2Leagues() / py get_tippmixpro_v2(), get_tippmixpro_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-tippmixpro-v2] #### Source API book: Underdog (underdog) | Routes: GET /api/v2/underdog/{sport}; no /leagues route | Sports (all single-league, no ?league= needed): cs2, kbo, lol, mlb, mma, nba, ncaaf, nfl, nhl, npb, pga, soccer, tennis, valorant, wnba | WebSocket: REST only, no WebSocket event | SDK: ts getUnderdogV2() / py get_underdog_v2() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-underdog-v2] #### Source API book: Versus (versus) | Routes: GET /api/v2/versus/{sport}; league index GET /api/v2/versus/{sport}/leagues | Sports (all multi-league; ?league= REQUIRED, omitted = 400): afl, badminton, basketball, boxing, cricket, darts, esports, golf, handball, mlb, mma, nfl, nhl, padel, soccer, table-tennis, tennis, volleyball | WebSocket: versus-v2-update, subscribe with v2: { versus: { soccer: [leagueKeys] } } (WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on) | SDK: ts getVersusV2(), getVersusV2Leagues() / py get_versus_v2(), get_versus_v2_leagues() | REST access: All paying tiers (bench, rookie, mvp, hall_of_fame). Details: section [sub-versus-v2] Each book's NATIVE data: full market depth, every player prop the book's feed carries (see each book's section), alternate lines, period markets. Nothing is mapped, normalized or merged, and a v2 payload is never mixed into /odds or /realtime. Use it for the whole book; use /api/v1/{sport}/odds to compare books in one shape. - REST is open to every paying tier; the per-book events need WebSocket access. - League keys are the book's own (slugs, series tickers, tag slugs) and move with the calendar: read them from /leagues, never hard-code them. Sport keys are the book's own where they differ from v1 (Thunderpick "baseball", Hard Rock "ICE_HOCKEY", Stake "ice-hockey"). - ETag: echo the ETag header verbatim (the hash in double quotes) in If-None-Match for 304; a bare hash will not match. Responses are Cache-Control: no-store, so cache client-side with the ETag, not at a CDN. - Envelope: { success, sport, league?, marketCount, etag, data: { : }, meta: { source, cached, status, cacheTimestamp, ageSeconds, timestamp } }. meta.status ok | stale | partial | no-data; stale means older than that book's freshness bar and is a hint, not an error. The bar is per book: 30 seconds unless the book's section below states one, and the stated bars run from 15 seconds to 750 seconds, so never gate on a single value across books. mybookie, thunderpick, underdog, stake and hardrock return data as an array with a count. - One data entry is one market for most books, but a whole EVENT with all of its markets for 888sport, ballybet, bet105, betrivers, betus, fanaticsmarkets, novig, polymarket and versus. WebSocket deltas: opt in with the v2 subscribe field ("*" = every league of a sport, or an array of league keys); omitting it yields zero v2 messages. Only changes are published, so seed from REST first. Most events carry one changed market, { marketId, hash, events, markets, sport, league, leagueName, etag, timestamp, data: }; demultiplex on msg.sport. Exceptions, detailed per book: betonline-realtime nests a markets array and has no payload.data; mybookie-v2-update batches changed events plus removedGhids; thunderpick-v2-update carries no market data; pinnacle-v2-update carries changed matchupIds plus the league hash; kalshi-v2-update and polymarket-v2-update carry the Kalshi or Polymarket message in raw, exactly as it was sent. ## [sub-4casters-v2] 4casters (v2) (docs: https://owlsinsight.com/docs#sub-4casters-v2) | Sports, league rules and the WebSocket event for 4casters: see "Source API book: 4casters (4casters)" in [source-api]. 4casters (4casters.io) is a peer-to-peer betting EXCHANGE, not a sportsbook, and belongs with Novig, ProphetX, Kalshi and Polymarket: every price is a resting offer somebody posted, so each market is an ORDER BOOK rather than a single quote. A data entry is one EVENT with its whole order book, keyed by the exchange's own 24-character hex game id. The entry carries participants (longName, shortName, homeAway, rotationNumber), start (ISO time), live and ended flags, league and sport, and the books themselves: awayMoneylines and homeMoneylines are arrays of rungs, awaySpreads and homeSpreads are objects keyed by the spread line, over and under are objects keyed by the total. Each rung is one resting offer with odds (AMERICAN, and half-point lines occur), sumUntaken (the size still available at that price), takenRatio, matchedVolume where present, bet and risk, and createdAt. Player props are separate events with isSpecials true, an eventName naming the player and market (for example a total receiving yards line), and parentGameID pointing at the game; they live in the -props leagues such as nfl-props. Multi-league sports need ?league=; the aggregate views ALL and FEATURED-SOCCER are league keys too. Sports carried: nfl, ncaaf, mlb, nba, ncaab, wnba, nhl, soccer, tennis, esports, mma, cfl. The freshness bar is 360 seconds: a quiet league is rewritten about every 4 to 5 minutes, so stale here means a write was missed, and after 10 minutes with no write the league's entries expire and it reads no-data. 4casters-v2-update carries a whole event per message. WebSocket subscribers must keep their base odds subscription NARROW (a couple of sports and books): a wide base subscription queues a multi-megabyte snapshot that can exceed the per-socket backlog cap and the socket is shed before any v2 event arrives. ## [sub-888sport-v2] 888sport (v2) (docs: https://owlsinsight.com/docs#sub-888sport-v2) | Sports, league rules and the WebSocket event for 888sport: see "Source API book: 888sport (888sport)" in [source-api]. 888sport (888's own sportsbook) listings, verbatim. The data map is keyed by 888sport's own EVENT id and each entry is one whole game: name, start_time, tournament_name, competitors (keyed by id, each with name and is_home_team) and markets keyed by market id; a market has a name and selections keyed by selection id, each with name, decimal_price and fraction_price (strings such as "1.909" and "10/11"; decimal and fractional only) and special_odds_value (the line on a spread or total such as "+3.5" or "47.5", empty on a moneyline). marketCount counts games. Only the main markets 888sport lists with each game are carried, with no player props and no alternate lines: US games (nfl, ncaaf, nba, ncaab, wnba, nhl, mlb) carry Money Line, a spread (Point Spread, Run Line in MLB, Puck Line in NHL) and a total (Total Points / Total Runs / Total Match Goals Over/Under); soccer and tennis carry the match-winner market ONLY (Match Winner with home, Draw and away selections in soccer, To Win Match in tennis). Each US sport is one league, so no ?league= is needed; soccer and tennis are keyed by 888sport's tournament slug (for example uefa-nations-league) and need ?league= (400 without it), read from /leagues. No WebSocket event: poll the REST route with If-None-Match. The freshness bar is 150 seconds. ## [sub-ballybet-v2] Bally Bet (v2) (docs: https://owlsinsight.com/docs#sub-ballybet-v2) | Sports, league rules and the WebSocket event for Bally Bet: see "Source API book: Bally Bet (ballybet)" in [source-api]. Player props: yes, the same depth as BetRivers (hundreds per NFL game, and MLB games carry them too), in this Source API data rather than on the /api/v1/{sport}/props endpoints. Bally Bet runs on the same shared sportsbook platform as BetRivers, so everything in the BetRivers entry applies here: data is keyed by EVENT id and each value is { event, betOffers }, a whole game (iterate entry.betOffers); the native odds value is DECIMAL x1000, so use oddsAmerican / oddsFractional; player props are ordinary offers with the player in outcome.participant and the number in outcome.line; a merged call is byte-capped (meta.status "partial", marketsOmitted), so use ?league= for a complete league. The only difference in the envelope is meta.source "ballybet-v2". Shared with BetRivers: the sport keys, the event ids (the same game has the same event id on both, so the two books join on it), and a catalog built the same way, including deep player props; most league keys match, and Bally Bet adds its own boost leagues (below). Different: the prices are Bally Bet's own. Most match BetRivers, but a share differ, by 1 to 15 cents where they differ, most visibly on some main lines. The two share one platform, so do not treat them as independent books when comparing prices. Bally Bet's own price boosts come through under sport specials, in leagues such as nfl-boosts and mlb-boosts; each boost is its own event with its own id. ballybet-v2-update has the same shape as betrivers-v2-update: each message carries one league's changed events, and each entry is a whole event. The freshness bar is 180 seconds. Every mma league (UFC is ufc) is refreshed in full about every 30 seconds, stretched when many MMA events are listed at once, so at normal load every fight, live or not, stays inside the bar; every other league follows the refresh cadence in the BetRivers entry. ## [sub-bet365-v2] Bet365 (v2) (docs: https://owlsinsight.com/docs#sub-bet365-v2) | Sports, league rules and the WebSocket event for Bet365: see "Source API book: Bet365 (bet365)" in [source-api]. data is keyed by market bucket ("mainlines", "points-ou", "spread-alt", ...), each { sport, classId, marketId, fixtures[], markets[] }, and the envelope adds totalFixtures and totalOutcomes. Buckets vary by sport and slate (game lines, halves and quarters, alternate lines, player points / rebounds / assists / threes, run lines and pitcher props, puck lines and goalscorers, full-time result, both teams to score), so iterate the data keys. Each outcome carries odds ("10/11", native fractional), americanOdds (-110), handicap ("-1.0"), fixtureId, status and, when known, gameFixtureId. status is "1" for an open price and "0" for a suspended one (Bet365's suspension flag, in-play buckets included); a suspended price can still carry its last odds, so check status before using a price. handicap carries the line on game lines (spread and total point), over/under props, team totals and alternate totals (always the plain number: "6.5", never "O 6.5" or "+6.5"), and the threshold on milestone grids. On the alternate-line buckets NFL spread-alt, MLB alt-run-line and NHL puck-line-alt the line is in the outcome name and handicap is empty. On MLB alt-game-total and NHL total-alt the line is not in the payload: name and handicap are both empty. On soccer total-goals handicap is the line (1.5, 2.5, ...) and name is Over or Under. Game-line prices (NFL, MLB and NHL game-lines, NHL 1st-period), the totals in the in-play live-* buckets, NFL team-points and total-alt, and soccer total-goals prices also carry side: away or home on the spread and moneyline, over or under on totals. Each game's first spread or moneyline price is the away team and the second the home team, and name is the team (Over or Under on totals) wherever Bet365 sends no name of its own; the MLB Run Line keeps the line Bet365 puts in name. side is omitted on 3-way markets (on the NHL 3-way total only the Over and Under carry it) and on any game Bet365 does not lay out as one away price and one home price. handicap is also empty wherever a market has no line, such as moneylines and goalscorers. Soccer league buckets follow live coverage (top-leagues, uk, uefa, spain, ...); read them from /leagues. fixtures[].startTime is Bet365's UK local time (Europe/London) as YYYYMMDDHHmmss, not UTC: one hour ahead of UTC during British Summer Time. fixtures[].startTimeUtc is the start as an ISO 8601 UTC time ("2026-09-27T17:00:00.000Z"); use it to compare with other feeds. On NFL a fixture tied to its game takes the start of that game's game-lines entry, so it can differ from its own startTime where a bucket's start is off (spread-alt). startTimeUtc is omitted when startTime is empty, and for a start in the local hour that happens twice when UK clocks go back (01:00 to 01:59 on the last Sunday of October), which Bet365's value cannot resolve: an 8:08 pm and a 9:08 pm Eastern start on the Saturday before both read 01:08. NCAAF (GET /api/v2/bet365/ncaaf) is game lines only: one bucket, game-lines, with a spread, a total and a moneyline per game, and no alternate-line or player-prop buckets. Bet365 lists no NCAAF player props, so never point a player props question for NCAAF at this route. NFL buckets, as offered by Bet365 (iterate the data keys): game lines (game-lines: spread / total / moneyline), alternative spreads (spread-alt), alternative totals (total-alt), team totals (team-points), touchdown scorers (touchdown-scorers: First and Anytime), multi-touchdown scorers (multi-touchdown-scorers), and player over/under and milestone props, including Tackles and Assists O/U (tackles-assists-ou). No last-touchdown-scorer or sack markets yet. Over/under props list Over and Under as separate markets; a milestone grid has one price per player per threshold, the threshold in handicap. spread-alt has one market per team, named as in game-lines homeTeam / awayTeam, with the line in the outcome name and handicap empty. team-points has one market per team, named for the team, with each of its totals (main and alternates) as an Over and an Under price: handicap is the line, side over or under, name Over or Under. total-alt has Over and Under markets per game, the main line first and then the alternatives: handicap is the line and side over or under; the alternative prices are named Over or Under and the main-line prices keep the line Bet365 puts in name. touchdown-scorers has a First and an Anytime market per game (handicap empty). multi-touchdown-scorers has the markets "2 Or More" and "3 Or More" (the threshold is in the market name, handicap is empty). Every one of these buckets lists one fixtures[] entry per game; join on gameFixtureId as below. A spread-alt fixture's startTime can be an hour off its game-lines entry, which is one more reason to join on gameFixtureId; its startTimeUtc is the game's (see below). Bet365 offers no Solo Tackles or standalone Assists market. Joining a prop to its game on NFL, MLB and NHL: over/under and milestone prop buckets list one fixtures[] entry per game (description with the matchup, homeTeam, awayTeam, startTime, league), and an outcome's fixtureId finds its entry in that same bucket. There fixtureId is not the game id, so never join a prop bucket to game-lines on it: a prop bucket's fixtureId never appears in game-lines, and inside game-lines the total and moneyline prices carry sub-fixture ids that are not in fixtures[]. Join an outcome's gameFixtureId (Bet365's id for the game, equal in every bucket) to fixtures[].gameFixtureId or to a game-line outcome's gameFixtureId, never to fixtures[].fixtureId: on MLB game lines the two differ. Game-line prices and over/under prop prices carry gameFixtureId, and any fixtures[] entry, game-lines or prop, carries it when one of its own priced outcomes did. On NFL every bucket carries it: where Bet365 sends no game id on a price (spread-alt, team-points, total-alt, the milestone grids), it is filled from the game-lines entry with the same matchup on the same date, and from the game id the other buckets give the same fixtureId, and left out wherever those disagree or the game is not listed. On NFL total-alt the alternative lines of a game carry it once Bet365 lists player props for that game; its fixtures[] entries and main lines carry it from the start. MLB and NHL milestone grids (MLB hits, NHL points, ...) and MLB alt-run-line have none: match their fixtures[] entry to the game on description + startTime. NHL puck-line-alt and total-alt have no documented join to their game yet: puck-line-alt fixture start times differ from game-lines, and total-alt outcomes do not point at their bucket's fixtures[]. On soccer, fixtureId is the match's own id, the same in every bucket, so join on it: match-level prices also carry gameFixtureId with the same value, prop outcomes carry none, and a prop outcome (goalscorers, player-shots, ...) finds its match in the fixtures[] of a match-level bucket such as ftr, btts, double-chance or total-goals, when one of them lists that match. bet365-v2-update has two variants: deltaType "snapshot" (full bucket, always carries hash; replace your copy) and deltaType "outcomes" (a changedOutcomes patch list, possibly chunked with chunkIndex / chunkCount, hash only on the last chunk). Merge outcomes by id and commit msg.hash only when present. The freshness bar is 750 seconds, above the worst measured full sweep, so a tighter bar would report "stale" on a healthy board. ## [sub-betonline-v2] BetOnline (v2) (docs: https://owlsinsight.com/docs#sub-betonline-v2) | Sports, league rules and the WebSocket event for BetOnline: see "Source API book: BetOnline (betonline)" in [source-api]. NAMING EXCEPTION: the event is betonline-realtime, the only v2 book off the -v2-update pattern. There is no betonline-v2-update event; it does not exist, and subscribing to it produces silence rather than an error. The payload nests a markets ARRAY of { marketId, hash, kind ("market" | "event"), data } and has no payload.data. The bare call merges every competition: league null, meta.merged true, meta.leagueCount, and a top-level leagues array ({ leagueKey, leagueName, marketCount, marketIds }) attributing each market; data stays the same flat map. The merge is capped at 40 competitions and 1500 markets (meta.status "partial" plus truncated / leaguesOmitted / marketsOmitted when cut). Planes: mlb, nba, nhl and nfl carry BOTH live in-play and pregame documents; tennis and soccer are LIVE ONLY, so with nothing in play they return no markets. Both planes share the endpoint and the event, so branch on the _inplay boolean every document carries. Live markets are deltas flushed at most once per second per (sport, league); pregame is republished every 30 seconds. Four document kinds, keyed by LOWERCASED marketId: "-event" event metadata on both planes (id, sport, competition, start, isLive, participants, _inplay; live adds period and extraMarkets, a COUNT). No markets, picks or marketType. "-event-detail" LIVE ONLY; may embed a catalogue that duplicates the standalone records below, so prefer those. "--" LIVE market, three segments ("1693065-total-39402429"): title, marketType, period, columns, picks, isOnHold, _inplay. "-" PREGAME market, two segments ("31234567-total"): title, marketType, picks, eventId, isOnHold, _inplay; NO columns, NO period. Test for marketType to separate markets from event documents, then branch on _inplay. The marketType FIELD stays UPPERCASE ("TOTAL", "HOME_WILL_BE_HIT") while the key segment is lowercase. Period is a field: an MLB 7th-inning total (period "INNING") and the full-game total ("MATCH_TIME") are two documents with marketType TOTAL. Pregame carries moneyline, spread and total only; live adds period variants and props under BetOnline's own names (WILL_GO_OVERTIME, TOTAL_SETS). Picks price through oddsContainer (american always present; decimal and fractional omitted on pregame); live picks are keyed by composite selection id with wagerType, points, propId, periodId, overUnder, pregame picks by away / home / over / under with detailLabel, oddsContainer and points. meta caveat: the freshness bar is 180 seconds and ageSeconds is the OLDEST document shipped, so a league with fresh live prices can report "stale" or "partial" while a settled market lingers until the 600 second TTL; judge freshness from the documents. ## [sub-propbuilder-v2] Prop Builder (v2) (docs: https://owlsinsight.com/docs#sub-propbuilder-v2) | Sports, league rules and the WebSocket event for Prop Builder: see "Source API book: Prop Builder (propbuilder)" in [source-api]. Prop Builder is the player props board several sportsbooks embed beside their main book (book key propbuilder). Records are served exactly as the board publishes them: ODDS ARE DECIMAL, and a market's oddsOverride replaces odds when it is greater than 1. The data map holds one entry per game and market family, family first: "game-" (the game: home, away, startTime, status pregame, the game record, the board's tab map in categories, and entries, the ids stored for that game), "ou---" (Over/Under lines for one statistic; each player carries an over market, type 18 condition 3, and an under market, type 19 condition 1, at the same value), "ss---" (an N+ milestone ladder for one statistic, type 1 condition 3, for example Touchdowns where value 1 is anytime), "gfm---" (one game-market group, sold as parlay legs only), "ppd-" (Player Doubles, two-leg combos sold at one price) and "h2h-" (player vs player). Every family entry is { gameId, family, statistic, data }. gameId is the board's own numeric game id, not the eventId of the v1 endpoints: join on home, away and startTime. Sports carried: nfl, ncaaf, nba, wnba, nhl and mlb, each single-league (no league parameter). ?marketIds= narrows the response: ou-*,ss-* returns every Over/Under and N+ board in the sport, and game-,ou--*,ss--*,gfm--*,ppd-,h2h- returns one game. Pregame only: a game leaves when it starts, and a market the board suspends or withdraws leaves its entry (a moved line appears as a new market id). A leg's price is not the price of a parlay. Several sportsbooks embed this board, and its prices were identical on every site we compared. propbuilder-v2-update pushes changed entries within seconds (each markets[] entry is a whole entry whose kind is its family; replace by marketId and drop removedIds): load the REST board on every connect, then apply the messages. The freshness bar is 120 seconds. Prop Builder is not the Same Game Parlay builder: it is a board of single-leg prices and does not price a combination of legs, and the builder ([sub-sgp]) prices only at fanduel, draftkings or hardrock, so it cannot price a Prop Builder slip (book: propbuilder is a 400 SGP_BOOK_UNSUPPORTED). ## [sub-betrivers-v2] BetRivers (v2) (docs: https://owlsinsight.com/docs#sub-betrivers-v2) | Sports, league rules and the WebSocket event for BetRivers: see "Source API book: BetRivers (betrivers)" in [source-api]. The widest sport list of any v2 book. Two things users get wrong: data is keyed by EVENT id and each value is { event, betOffers }, a whole game (iterate entry.betOffers); and the native odds value is DECIMAL x1000 (1170 = 1.17) with line scaled the same way (225000 = 225), so use oddsAmerican / oddsFractional. Player props are the reason to use this book: hundreds of bet offers per NFL game, most of them props (yardage lines, touchdown scorers, per-quarter player markets), as ordinary offers whose betOfferType.englishName starts with "Player", with the player in outcome.participant and the number in outcome.line. A merged call is byte-capped (meta.status "partial", marketsOmitted); use ?league= for a complete league. betrivers-v2-update carries a whole event per message. The freshness bar is 180 seconds on the OLDEST event in the response, but leagues are NOT all refreshed inside it: events live or starting within 6 hours about every 45 seconds, every other event about every 300 (longer on a busy slate). So a league holding any later event reads "stale" for part of each cycle (a merged call reports its worst league); that is routine, and only an age well past 300 seconds means updates stopped. ## [sub-betus-v2] BetUS (v2) (docs: https://owlsinsight.com/docs#sub-betus-v2) | Sports, league rules and the WebSocket event for BetUS: see "Source API book: BetUS (betus)" in [source-api]. Served from the book's live platform, which carries both a prematch and an in-play plane, so an entry can carry a live score and period. data is keyed by EVENT id; each value is one event with its whole market set in markets[], each market keyed by wagerId with name and selections[]. Odds are DECIMAL. A selection with side null is a single outcome keyed by outcome id; where side is 0 or 1 the key is the LINE and the two sides are its halves. state carries score and period in play, null otherwise. Market names are best-effort and say which kind they are: many arrive as name null with the wagerId intact, while the three primary game markets (moneyline, total, spread) are labelled from the market's STRUCTURE rather than the book's catalog and carry nameSource "inferred", so a deduced label is never mistaken for one the book published. Each entry reports both unnamedMarkets and inferredNames. Key on wagerId and never call a market missing because its name is null. Player props are markets inside the event ("Anytime Touchdown Scorers", "Player Passing Yards Bands"). A merged call is byte-capped (meta.status "partial"); use ?league= for a complete league. betus-v2-update carries a whole event per message. The freshness bar is 180 seconds; a quiet prematch board can sit unchanged for minutes, so "stale" here is not by itself a stall. ## [sub-bet105-v2] Bet105 (v2) (docs: https://owlsinsight.com/docs#sub-bet105-v2) | Sports, league rules and the WebSocket event for Bet105: see "Source API book: Bet105 (bet105)" in [source-api]. Bet105 (bet105.ag) is a reduced-juice crypto book whose line set is a PINNACLE MIRROR priced at -105, which is the reason to carry it: Pinnacle-shaped numbers at reduced juice. It runs on the same platform as BetUS under a different tenant, so the payload shape is identical: both a prematch and an in-play plane, data keyed by EVENT id, each value one event with its whole market set in markets[], each market keyed by wagerId with name and selections[]. Odds are DECIMAL, verbatim. A selection with side null is a single outcome keyed by outcome id; where side is 0 or 1 the key is the LINE and the two sides are its halves. state carries score and period in play, null otherwise. Each entry also carries competitors (the ordered participant list) and startTime (epoch SECONDS) because prices arrive on a per-event room with no names attached, so those two fields are what tell you which game an entry is; for a two-outcome moneyline competitors[i] maps to selection key String(i+1), which does NOT hold for 3-way markets where the middle selection is the draw, and home/away is deliberately not asserted. Market names are best-effort and say which kind they are: a market the book does not label arrives as name null with the wagerId intact, while the three primary game markets are labelled from the market's structure and carry nameSource "inferred", so a deduced label is never mistaken for one the book published; entries report both unnamedMarkets and inferredNames. Player props are markets inside the event. The board is capped per plane and ranked by kickoff, soonest first, so a sport can show fewer games than the book's own site lists and the leagues index reflects what is carried rather than the full catalog. A merged call is byte-capped (meta.status "partial"); use ?league= for a complete league. bet105-v2-update carries a whole event per message. The freshness bar is 180 seconds; a quiet prematch board can sit unchanged for minutes, so "stale" here is not by itself a stall. ## [sub-bookmaker-v2] Bookmaker.eu (v2) (docs: https://owlsinsight.com/docs#sub-bookmaker-v2) | Sports, league rules and the WebSocket event for Bookmaker.eu: see "Source API book: Bookmaker.eu (bookmaker)" in [source-api]. Sport groups and league keys are Bookmaker's own page slugs (basketball with nba, nba-futures, nba-series); the catalog follows its seasons, so enumerate /leagues first. NOT a live source: pages regenerate every 25 to 60 minutes, each market carries pageLastUpdate and the freshness bar is 360 seconds. Three entry shapes by kind: game (visitor / home rows plus draw for 3-way sports; priced moneylines, but spread and total are LINES ONLY without vig prices; values are verbatim strings like "+2", "217", "-138"), futures (option boards; option text is authoritative, name and price a convenience split absent when unpriced) and proposition (two-column priced markets, entries[{ datetime, name, price }]). ## [sub-bovada-v2] Bovada (v2) (docs: https://owlsinsight.com/docs#sub-bovada-v2) | Sports, league rules and the WebSocket event for Bovada: see "Source API book: Bovada (bovada)" in [source-api]. Bovada's own event objects verbatim, rewritten every few seconds: the events and markets of Bovada's public coupon feed, with in-play and pre-game prices on the US sports taken from Bovada's live push feed, so a line move arrives within a few seconds; an event's lastModified is then the time we received its latest change. While the push feed is unavailable those events come from the coupon alone, as mma always does, and can lag Bovada by several minutes. data is keyed by Bovada event id, each value with id, description, startTime, competitors[{ name, home, shortName }] and displayGroups[{ description, markets[{ description, outcomes[{ description, price: { american, decimal, fractional } }] }] }]. The US sports carry Bovada's main lines. mma is one league for every promotion Bovada lists (UFC and others; an event's link names its promotion, as in /ufc-mma/ufc/...) and carries every display group Bovada posts for a fight: Fight Odds, plus Round Props and Fight Props on the fights Bovada prices them for, typically every bout of the next UFC card. The mma league also holds UFC BJJ grappling matches (/ufc-mma/ufc-bjj/...) and occasional speculative matchups, listed under potential fights (/ufc-mma/ufc/potential-fights/...). Leagues out of season populate once they start. ## [sub-draftkings-v2] DraftKings (v2) (docs: https://owlsinsight.com/docs#sub-draftkings-v2) | Sports, league rules and the WebSocket event for DraftKings: see "Source API book: DraftKings (draftkings)" in [source-api]. data is keyed by DraftKings subcategory id for soccer (4514 Game Lines, 11273 Halves, 17865 Corners, 6828 Cards, 18725 Specials, plus whatever DraftKings exposes per event), WNBA and MMA, and by "main" (the game lines) for the other US sports, which can also carry a "live" bucket (a snapshot of the sport's live board, selections nested on markets). MMA is league ufc (GET /api/v2/draftkings/mma?league=ufc): 13025 (Moneyline, Point Spread and Total Rounds) holds every bout DraftKings prices; fight props sit in subcategories such as 18911 (KO/TKO/DQ, Submission and Decision markets, each per fighter), 6437 (Exact Method of Victory), 13308 (Round Betting: which fighter wins in which round, or by decision), 5798 (Winning Round: the round only) and 17644 (Fight to Go the Distance), on the bouts DraftKings prices them for. These are the market names inside each bucket; a bucket carries only its id. 10197 holds championship futures (one event per division, such as Men's UFC Heavyweight, each with a championship market, at present "To be Champion on 31st December 2027") and is the only bucket in a week with no bouts listed. 13025 refreshes about every 5 seconds, fight props about every 15 (every 5 while a bout is live). A subcategory bucket or "main" carries events[{ id, name, startEventDate }], markets[{ id, eventId, name }] and selections[{ marketId, label, displayOdds: { american, decimal, fractional } }]; the "live" bucket has no top-level selections array: it carries events[] and markets[], and each market holds its own selections[]. draftkings-v2-update publishes every changed bucket for soccer, WNBA and MMA, and for the other US sports only the "live" bucket, when it is small enough to send; their "main" bucket is REST only (poll with ETags). ## [sub-pick6-v2] DraftKings Pick6 (v2) (docs: https://owlsinsight.com/docs#sub-pick6-v2) | Sports, league rules and the WebSocket event for DraftKings Pick6: see "Source API book: DraftKings Pick6 (pick6)" in [source-api]. ## [sub-fanatics-v2] Fanatics Sportsbook (v2) (docs: https://owlsinsight.com/docs#sub-fanatics-v2) | Sports, league rules and the WebSocket event for Fanatics Sportsbook: see "Source API book: Fanatics Sportsbook (fanatics)" in [source-api]. ## [sub-fanaticsmarkets-v2] Fanatics Markets (v2) (docs: https://owlsinsight.com/docs#sub-fanaticsmarkets-v2) | Sports, league rules and the WebSocket event for Fanatics Markets: see "Source API book: Fanatics Markets (fanaticsmarkets)" in [source-api]. A PREDICTION MARKET settled on Nadex, not a sportsbook: a price is a probability between 0 and 1 (no American odds, no vig) and each outcome also carries totalVolume and tradeCount. NOT the Fanatics sportsbook, which has no public feed; never shorten the name to "Fanatics". League keys are series and the bare call merges them. data is keyed by EVENT id with a markets map keyed by group (moneyline, spread, over_and_under, outright). Player markets exist as outright events whose outcomes are individual players (an MVP award with a probability per player): season-long awards, not per-game props (send those questions to betrivers). Sports only; politics and crypto contracts are not served. The freshness bar is 180 seconds and every series is swept inside that window, so unlike betonline and lowvig a "stale" here does mean writes stopped. ## [sub-fanduel-v2] FanDuel (v2) (docs: https://owlsinsight.com/docs#sub-fanduel-v2) | Sports, league rules and the WebSocket event for FanDuel: see "Source API book: FanDuel (fanduel)" in [source-api]. FanDuel's Source API serves nine sports: mlb, nba, ncaaf, nfl, nhl and wnba (single-league, no ?league= needed) and soccer, tennis and mma (?league= required). data is keyed by "{eventId}-{tab}" ("35577678-batter-props", "35512833-corners"); each bucket nests attachments.events and attachments.markets, with runners (selectionId, runnerName, runnerStatus, handicap, winRunnerOdds: { americanDisplayOdds, trueOdds }) at attachments.markets[id].runners. Tabs vary by sport and slate (same-game-parlay, corners, goals, halves, shots on soccer; batter-props, pitcher-props, innings on baseball; player-points, player-rebounds, player-assists, team-props, race-to on basketball; per-period tabs on hockey; popular, same-game-parlay, method-of-victory, round-props on MMA; popular, quick-bets on most): read them from the response keys. Soccer Asian handicaps populate runners[].handicap; suspended runners pass through with runnerStatus "SUSPENDED". MMA: UFC is league ufc (GET /api/v2/fanduel/mma?league=ufc); another promotion FanDuel lists gets its own key in /leagues. A fight priced only for its moneyline carries just its popular entry, because a tab that repeats the popular tab is left out; the fights FanDuel prices deeper add method-of-victory, round-props and same-game-parlay tabs. fanduel-v2-update publishes one (eventId, tab) bucket per message for every FanDuel v2 sport; msg.league is the sport name for single-league sports and the league key for soccer, tennis and mma (ufc for UFC). ## [sub-hardrock-v2] Hard Rock Bet (v2) (docs: https://owlsinsight.com/docs#sub-hardrock-v2) #### GET /api/v2/{book}/{state}/{sport} | Returns raw Hard Rock Bet events with every market for one jurisdiction and sport, exactly as received. Prices arrive as a rootIdx integer per selection; decode them with the ladder endpoint. | Access: All paying tiers | Books: hardrock | Query: league (optional) - League slug or numeric compId from the /leagues index. Required for multi-league sports. | Response: { success, state, sport, league?, count, data: [raw events], meta: { source, status, ageSeconds, timestamp, ... } } | Note: state is the jurisdiction code (az or fl). sport is Hard Rock's own upper-case sport code, for example ICE_HOCKEY. | Note: Sports without a league index return their whole catalog in one response; passing league to one of them returns 400 rather than silently ignoring the filter. | Note: An unknown league slug returns 404 with a hint to the /leagues index. | Note: Multi-line markets (multiLine true, for example a player passing-yards market of type ...PROPPSYDSMULTILINE) carry Hard Rock's whole alternate ladder in selections: every Over and Under rung the book offers, not only the current line. The current line's selections are the live ones from the feed; the other rungs come from the ladder, which is refreshed most often for games in play, then for games starting within a few hours, and as soon as possible after a pre-game market's current line moves. multiLineLadderAt (ms epoch) is the time of the ladder fetch we last stored: a fetch that moves a rung is stored within a few seconds, and an unchanged ladder is rewritten at least every 30 seconds, so the stamp can trail the latest fetch by up to about 30 seconds when no price changed. Read it rather than assuming a cadence. hasMoreLines is Hard Rock's own flag: true means the ladder has more lines than the requested maxLines, so its farthest rungs are missing. A market Hard Rock reports closed or suspended loses its ladder and shows only its current line until it reopens. Ladders are enabled per sport (football first); a multi-line market without multiLineLadderAt shows only its current line. | Note: Send If-None-Match with the ETag response header, quoted exactly as received (a leading W/ is fine), to receive 304 when nothing changed. A 304 on /api/v2 does not count toward your monthly quota; it still counts toward your per-minute limit. | SDK: ts getHardRockEvents() / py get_hard_rock_events() #### GET /api/v2/{book}/{state}/{sport}/leagues | Returns the league index for a Hard Rock Bet sport in one jurisdiction: the slugs and compIds accepted as the league parameter. | Access: All paying tiers | Books: hardrock | Response: { success, state, sport, leagues: [{ slug, compId, name, ... }], meta: { source, status, timestamp } } | Note: 400 for sports that do not use league bucketing; call the events endpoint directly for those. | SDK: ts getHardRockLeagues() / py get_hard_rock_leagues() #### GET /api/v2/{book}/ladder | Returns Hard Rock Bet's complete rootIdx to American odds price ladder, for decoding the selections returned by the events endpoint. | Access: All paying tiers | Books: hardrock | Response: { success, data: { even, ladder: [{ rootIdx, americanOdds }] }, meta: { source, count, decoding, timestamp } } | Note: Look values up exactly. Never interpolate, round or clamp: the steps are not evenly spaced, and a rootIdx absent from the ladder means no price. | Note: Reference data: the response allows private caching for an hour. | SDK: ts getHardRockLadder() / py get_hard_rock_ladder() | Sports, league rules and the WebSocket event for Hard Rock Bet: see "Source API book: Hard Rock Bet (hardrock)" in [source-api]. state is az (Arizona) or fl (Florida) and precedes the sport in the path (/api/v2/hardrock/az/ICE_HOCKEY?league=nhl). The big sports (BASKETBALL, BASEBALL, ICE_HOCKEY, SOCCER) are bucketed by league (slugs like nhl, usa-nba, mlb from /leagues); the others return all events without a league. Each event carries id, name, inplay, sport, eventTime, participants[{ id, name, position }] and markets[{ id, type ("ICEHOCKEY:FTREG:ML", "ICEHOCKEY:PRP:SAV"), name, state, selections[{ id, name, rootIdx, type, state }] }]. WHERE ARE THE ODDS: each selection carries rootIdx, an index into Hard Rock's fixed price ladder, instead of odds; 72 is even money (+100), below 72 favorites, above 72 underdogs (42 = -325, 70 = -110, 80 = +140, 94 = +220). Objects without rootIdx are market containers. Decode with the ladder endpoint or the offline decoders (rootIdxToAmericanOdds in owls-insight-ts, root_idx_to_american_odds in Python), and track rootIdx changes to detect line movement. An MMA bout is kept until 4 hours after its eventTime, because Hard Rock gives each bout its card segment's start (prelims, main card, main event) rather than its own: a bout's eventTime can be hours before it is fought, and a finished bout can stay listed until 4 hours after its segment started. meta.status flips to "stale" at 15 seconds. ## [sub-kalshi-v2-endpoints] Kalshi (v2) (docs: https://owlsinsight.com/docs#sub-kalshi-v2-endpoints) | Sports, league rules and the WebSocket event for Kalshi: see "Source API book: Kalshi (kalshi)" in [source-api]. The league key is the LOWERCASE Kalshi series ticker (kxnbagame, kxnbapts, kxnbareb, kxeplgame, kxatpmatch, kxmlbhr), never a plain sport name: ?league=wnba returns nothing, WNBA's keys are tickers like kxwnbagame. One sport can have well over a hundred series (game lines, halves, spreads, totals, per-player props), which is why ?league= is required and /leagues is authoritative; player-prop series are first-class (kxnbapts is per-player points across every NBA game). data is keyed by lowercase market ticker with the raw Kalshi Market object (ticker, event_ticker, title, status, close_time, ...). Prices are DOLLAR STRINGS with 4 decimals (yes_bid_dollars "0.4400", yes_ask_dollars, no_bid_dollars, no_ask_dollars, last_price_dollars) and volumes and sizes are contract-count strings (volume_fp, volume_24h_fp, open_interest_fp, yes_bid_size_fp); Number() them, there are no bare yes_bid / volume fields. MMA keys are UFC and MMA series tickers such as kxufcfight, kxufcmov and kxufcrounds, never ufc (GET /api/v2/kalshi/mma?league=kxufcfight); a series is listed in /leagues only while it has markets, so take the open ones from there. MMA is REST only: kalshi-v2-update carries no MMA series. Kalshi data on these endpoints updates about every 45 seconds, so judge freshness by meta.ageSeconds; meta.status can read "stale" between updates. kalshi-v2-update: { sport, league, marketId, marketTicker, timestamp, raw } with raw the orderbook_delta / trade / orderbook_snapshot message exactly as Kalshi sent it. ## [sub-lowvig-v2] LowVig (v2) (docs: https://owlsinsight.com/docs#sub-lowvig-v2) | Sports, league rules and the WebSocket event for LowVig: see "Source API book: LowVig (lowvig)" in [source-api]. LowVig runs on the SAME platform as BetOnline and shares its event ids, market ids, document shapes, key casing and _inplay flag ([sub-betonline-v2]); its event follows the normal name, lowvig-v2-update. Its value is the PREGAME spread and total price (the reduced juice it is named for), which differs from BetOnline on most of those markets, while its live book and pregame moneylines match BetOnline's; do not describe it as an independent book. It carries player props on the PREGAME side as one market per player titled like "Bukayo Saka Specials" (marketType CONTEST, marketId -c) whose selections are that player's lines (2+ Shots, Goal Or Assist, Carded); the same mechanism adds correct score, both teams to score, corners, cards, winning margin, draw no bet, double chance and half-time/full-time. No ?player= filter is needed: scan market titles for the name. Live in-play player props are not promised. Prices come as decimal, American and fractional. The freshness bar is 180 seconds and, as on betonline, ageSeconds is the oldest document shipped, so a league with fresh live prices can still report "stale"; judge freshness from the documents. ## [sub-mybookie-v2] MyBookie (v2) (docs: https://owlsinsight.com/docs#sub-mybookie-v2) | Sports, league rules and the WebSocket event for MyBookie: see "Source API book: MyBookie (mybookie)" in [source-api]. MyBookie's public SignalR/MessagePack hub verbatim: pregame, in-play and player props on one channel, each event shipping its complete market state on every update (markets keyed by gmid, replaced wholesale). data is an array of games { ghid (stable game id), awayTeam, homeTeam, commenceTime, leagueId (710 MLB, 1167 NBA), sport, markets[{ gmid, gmlmid (market type), gma, gms ("O" open), ghl, omdl[{ oid, opid, pts, odd, os, onm }] }], liveScore, lastUpdateMs }. gmlmid ids differ by sport: MLB 314 moneyline, 319 run line, 321 total; NBA 285 moneyline, 289 spread, 291 total. The spread and total ids carry the main line and the alternate lines, one gmid per line. Other gmlmid values (player props, team totals, periods, winning margins) are not listed here and can differ by sport too; omdl.onm names what an outcome prices. omdl.odd and omdl.pts are STRINGS ("-115", "-1.5") or null; on a total the Under's pts is the line negated (Over "241.5", Under "-241.5"). meta.hash matches the ETag header. mybookie-v2-update sends one message per sport per cycle only when something changed: msg.data holds the changed events (upsert by ghid) and msg.removedGhids the ones to delete, with count, totalCount, hash and timestamp. ## [sub-novig-endpoints] Novig (v2) (docs: https://owlsinsight.com/docs#sub-novig-endpoints) | Sports, league rules and the WebSocket event for Novig: see "Source API book: Novig (novig)" in [source-api]. Novig is a peer-to-peer exchange whose main lines also appear inside /api/v1/{sport}/odds with exchange: true; the v2 route carries the FULL ladder verbatim, every strike plus the live order books (bids and asks). Two league keys per sport: "{sport}" (moneyline, spread, total and first-half markets) and "{sport}-props" (player props); omitting ?league= merges both, so GET /api/v2/novig/{sport} returns everything. One marketId per event with the blob { event, books }; prop entries carry a pp- prefix so they never collide when merged. Refreshes on the main odds cadence (stale past 30 seconds). REST only; the SDKs do not wrap this book yet, so call it with fetch and the Bearer header. ## [sub-pinnacle-v2-endpoints] Pinnacle (v2) (docs: https://owlsinsight.com/docs#sub-pinnacle-v2-endpoints) | Sports, league rules and the WebSocket event for Pinnacle: see "Source API book: Pinnacle (pinnacle)" in [source-api]. Pinnacle's own feed, verbatim: matchup metadata plus ALL markets, game lines and specials including player props. Every sport takes ?league=: the sport key itself for single-league sports (?league=wnba), the slugified Pinnacle league name from /leagues for tennis, soccer, esports and handball. data is keyed by stringified matchupId; each matchup carries id, league { id, name, sport } and a topics map keyed by feed topic (reg/lg/{lid}/pre and reg/lg/{lid}/live/... for game lines; spc/lg/{lid}/... for specials). A topic record carries id, startTime, isLive, status, participants[{ id, alignment, name, rotation }] and markets[{ key ("s;0;m", "s;0;ou;22.5"), type (moneyline | spread | total | team_total), period, status, isAlternate, cutoffAt, version, prices[{ designation | participantId, price, points }], limits[{ type: "maxRiskStake", amount }] }]; specials add special { category: "Player Props", description: "Caitlin Clark Total Points" } with Over / Under participants. Prices are American. Pattern: fetch a snapshot and keep etag, subscribe to pinnacle-v2-update, and on each event ({ sport, league, matchupIds changed since the last publish, hash = the league etag after the change, timestamp, heartbeat: true on the 30 second convergence ping }) refetch with If-None-Match set to the last etag. ## [sub-polymarket-v2-endpoints] Polymarket (v2) (docs: https://owlsinsight.com/docs#sub-polymarket-v2-endpoints) | Sports, league rules and the WebSocket event for Polymarket: see "Source API book: Polymarket (polymarket)" in [source-api]. The league key is a Polymarket tag slug from /leagues (nba, mlb, soccer, epl, ucl, mls, tennis, atp, wta; soccer has dozens). data is keyed by lowercase event slug ("nba-min-sas-2026-05-12"), or by the numeric event id when the slug is longer than 64 characters, with the raw Gamma event (id, slug, title, startDate, ...) and its FULL nested markets[]: game lines (sportsMarketType moneyline, spreads, totals) AND player props (points, rebounds, assists, threes, blocks, steals, turnovers, fantasy_points, tennis_match_totals) in one response, each with question, outcomes, outcomePrices, bestBid, bestAsk, line, clobTokenIds. outcomes, outcomePrices and clobTokenIds are JSON-encoded STRINGS on the wire (JSON.parse them); prices are probabilities between 0 and 1. Soccer events are often double-tagged (umbrella "soccer" plus a competition slug) and appear under each matching league key. MMA's league is the ufc tag (GET /api/v2/polymarket/mma?league=ufc), which holds upcoming fights alongside longer-range UFC markets, and also recently finished fights whose markets have resolved (a resolved market has closed: true and outcomePrices ["1", "0"] or ["0", "1"]; the event's own closed can still read false, so check each market); MMA is REST only: polymarket-v2-update carries no MMA. polymarket-v2-update: { sport, league, marketId (= eventSlug), eventSlug, marketType (the sportsMarketType, so prop deltas ride with game lines), assetId (CLOB token), timestamp, raw } with raw the price_change / book / last_trade_price message exactly as Polymarket sent it. ## [sub-polymarketus-v2] Polymarket US (v2) (docs: https://owlsinsight.com/docs#sub-polymarketus-v2) | Sports, league rules and the WebSocket event for Polymarket US: see "Source API book: Polymarket US (polymarketus)" in [source-api]. A PREDICTION MARKET, and NOT global Polymarket: Polymarket US is the CFTC-regulated US exchange, with its own events, ids and prices, so its data does not line up one to one with /api/v2/polymarket/{sport}. Never merge the two or call one by the other's name. A price is a contract price in US dollars between 0 and 1 (outcomePrices, bestBidQuote, bestAskQuote, marketSides[].price), read as an implied probability, not American odds. REST only: there is no WebSocket event. data is keyed by the event slug (nfl-cin-bal-2026-10-25): the event as Polymarket US lists it, with its markets[]. outcomes and outcomePrices are JSON-encoded strings, as on Polymarket. An NFL game's listing carries only its headline market (the moneyline); futures and most soccer games carry every market. Where an event has more markets than its listing shows, a second entry, full-{slug}, holds every market (spreads, totals, halves and quarters, player props). full-* entries are left out unless ?marketIds= asks for them: ?marketIds=full-{slug} for one game, ?marketIds=full-* for all of them, ?marketIds=* for everything; meta.defaultExcluded says how many a plain request left out. So for Polymarket US spreads, totals or player props, use full-{slug} when the event has one; an event with no full- entry (futures, most soccer games) already lists every market in its plain entry. One response is capped at 32 MB, soonest events first: past the cap meta.status is partial, meta.truncated is true, meta.marketsOmitted counts the events left out and meta.omittedIds lists up to 100 of them, which can then be asked for by id (?marketIds= takes up to 50 ids per request). Every sport needs ?league= (a bare call returns 400). League keys are Polymarket US's own slugs: nfl, cfb (NCAAF), nba, epl, atp, cs2 and so on, and a league's season-long futures are a league of their own (nfl-futures beside nfl). A sport key groups the venue's leagues: nba also carries other basketball leagues, nhl other hockey leagues. Take the keys from GET /api/v2/polymarketus/{sport}/leagues rather than guessing. ## [sub-stake-v2] Stake (v2) (docs: https://owlsinsight.com/docs#sub-stake-v2) #### GET /api/v2/{book}/bets | Returns a sample of Stake's public bet feed: recent sportsbook bets with real stake amounts and their legs, joined to the same market and outcome ids as the Stake v2 board. | Access: All paying tiers | Books: stake | Response: { success, count, data: [bets], meta: { source, cached, status, timestamp, ageSeconds, oldestBetTime, newestBetTime, sampling, hash, now } } | Note: A sample, not a ledger. The source caps each query at 50 rows and gives no cursor, so completeness can never be asserted; above roughly 200 bets a minute the cap binds and the window is undersampled. Do not derive total handle from it. | Note: Every meta key is present on every response shape. On no-data and partial-miss, timestamp, ageSeconds, hash and the bet times are null (there is no data to date) and now is the response clock; never read freshness from a null timestamp. | Note: Every response carries meta.sampling, and sampling.complete is always false. sourceLimit is always present. On an empty response the measured fields windowSeconds and observedNewPerMinute are null; the configured fields pollIntervalMs, configuredWindowMs and configuredMaxEntries are still reported when the feed has run within the last day, and are null only when even that is unknown. observedNewPerMinute is also null on a healthy response until a rate has been measured, which is normal right after a restart. | Note: Legs join to the Stake v2 board on outcome.id and market.id, never on fixture.id, which is a UUID in a different id space. For a fixture-level join use fixture.slug: its numeric prefix appears to be the board fixture id ("46853667-sporting-galatasaray-istanbul" matched board fixture 46853667). Verify that against your own data rather than assuming it. | Note: Each leg carries fixture.tournament { id, name, slug, category } so a wager can be attributed to a competition without matching it to a board first. Observed on every leg sampled. There is no sport field and no tournament-to-sport lookup: category is a grouping (UEFA Champions League gives "International Clubs"), not a sport. | Note: The feed is not filtered by sport, so bets on any sport Stake books can appear, including sports the per-sport board endpoint does not carry (cricket and handball both occur). There is no sport path segment. Any one response is a bounded rolling window of a few minutes, so a low-volume sport may be absent from it. | Note: data[].amount and currency are null when the bettor hid their stake; payout, payoutMultiplier, potentialMultiplier and each leg's odds are nullable too. Skip those rows rather than coercing them to zero. | Note: data[].createdAt and updatedAt pass through in the source's RFC 1123 format; the meta timestamps are ISO 8601. | Note: Bettor identity is never included. | SDK: ts getStakeBets() / py get_stake_bets() | Sports, league rules and the WebSocket event for Stake: see "Source API book: Stake (stake)" in [source-api]. Stake.com boards parsed from its server-rendered pages: the full board per fixture (match result, handicaps, totals, a deep set of props), decimal odds, exact wire values. NOT low latency: boards refresh on a polling cycle of a few minutes and meta.status flips to "stale" past 90 seconds, so never present this book as real-time. data is a StakeFixture[] with a count: id, name, slug, path, tournament, startTime, status and markets[{ id, name, status, provider, specifiers, outcomes[{ id, name, odds (decimal), active, extId }] }]; fields vary by fixture and sport. On ice-hockey a fixture also carries swish when Stake prices player props for it (NHL shots on goal, goals, assists, points, hits, plus minus, power play points, blocked shots, and goalie saves once priced): { id, status, startTime, players[{ id, name, position, markets[{ id, stat: { name }, lines[{ id, name, line, over, under, balanced, suspended }] }] }] }, over and under in decimal odds; markets with no line are left out and a game in progress has none. The ETag header is weak (W/"..."). The public bet feed (GET /api/v2/stake/bets, block above) is a sample, not a ledger; its legs join to these boards on outcome.id and market.id and its status flips to "stale" past 60 seconds. ## [sub-thescore-v2] theScore (v2) (docs: https://owlsinsight.com/docs#sub-thescore-v2) | Sports, league rules and the WebSocket event for theScore: see "Source API book: theScore (thescore)" in [source-api]. theScore Bet (Canada/Ontario) raw GraphQL competitionSection blob per competition, verbatim, refreshed about every 20 seconds. One marketId per competition: for the single-league sports (mlb, nfl, ncaaf, nba, nhl) the marketId equals the sport and no ?league= is needed; soccer and tennis carry many competitions, so ?league={competitionSlug} is required and each competition is its own marketId. Each blob holds the competition's events with their markets and outcomes; odds are { numeratorLong, denominatorLong, formattedOdds }. numeratorLong/denominatorLong IS the decimal price (43/20 = 2.15 = +115), NOT UK fractional, so never add 1; it is exact, so use it for precision. formattedOdds is that price preformatted American and is not always numeric: "Even" for a 2/1 rational (+100), so parseInt can return NaN. A selection's name is an object; read name.defaultName. List the available competitions for a sport with the /leagues endpoint. Out-of-season competitions appear once they start. The freshness bar is 60 seconds, which is the number to gate on rather than the 20 second refresh cadence. ## [sub-thunderpick-v2] Thunderpick (v2) (docs: https://owlsinsight.com/docs#sub-thunderpick-v2) | Sports, league rules and the WebSocket event for Thunderpick: see "Source API book: Thunderpick (thunderpick)" in [source-api]. Thunderpick.io's public catalogue in its list-view wire shape, sport keys Thunderpick's own (baseball, basketball, american-football). data is an array of events with a count: { id, sport, name, startTime (ISO; no startsAt), isLive, teams: { home: { id, name, shortName }, away }, competition: { id, name, shortName }, score (while live), preferredMarkets[] (featured lines with selections[{ id, name, odds, handicap, total, type, status }], baseLine, isMainLine), markets[] (full depth including player props when the event is open; may be EMPTY pregame while preferredMarkets still ships previews), lastUpdateMs }. Team names are at teams.home.name (flat homeName / awayName come through null); league is competition.name; selections[].total is a STRING or null; odds is decimal; type is "home", "away" or "other"; player props are markets whose name starts with "Player ". meta.hash matches the ETag. thunderpick-v2-update is a notification only ({ sport, count, hash, timestamp }, no events), published when a sport's hash flips: refetch REST on receipt or dedupe on msg.hash. ## [sub-tippmixpro-v2] TippmixPro (v2) (docs: https://owlsinsight.com/docs#sub-tippmixpro-v2) | Sports, league rules and the WebSocket event for TippmixPro: see "Source API book: TippmixPro (tippmixpro)" in [source-api]. TippmixPro is Hungary's state sportsbook (Szerencsejáték Zrt.), running on the EveryMatrix OddsMatrix platform. Records are served exactly as the book publishes them: ODDS ARE DECIMAL (bettingOffers[].odds, for example 1.87) and names are English. The data map is keyed by MARKET id, plus one entry per event keyed "-event". A market entry holds four arrays of the book's own records: market (name, bettingTypeName, eventPartName such as Whole Match or 1st Quarter, paramFloat1 for the line, mainLine, eventId), outcomes (translatedName, paramFloat1, paramParticipantName1), relations (marketId to outcomeId links) and bettingOffers (outcomeId, odds, isLive, isAvailable, lastChangedTime in ms). Join a price to its outcome by outcomeId. market.eventId names the "-event" entry, which holds the match record (name, startTime, homeParticipantName, awayParticipantName, parentName for the competition) plus eventInfos (live score, clock and period) and eventStatistics. Sports carried: nfl, ncaaf, nba, ncaab, nhl and mlb (each single-league, no league parameter needed), and soccer, tennis, mma, boxing, cs2, lol, dota2 and valorant, which REQUIRE ?league= (the API answers 400 without it; list keys with /leagues). Soccer carries the top competitions only (epl, efl-championship, laliga, serie-a, bundesliga, ligue-1, eredivisie, primeira-liga, scottish-premiership, belgian-pro-league, super-lig, hungary-nb-i, mls, liga-mx, brasileirao, argentina-liga-profesional, ucl, uel, uecl, uefa-nations-league, domestic cups): per competition, the next 8 matches plus every live one; the other sports carry the next 14 days. Prices are PUSHED by the book as they change, so tippmixpro-v2-update carries one market per entry, replaced wholesale by marketId, with removedIds for markets that closed. The freshness bar is 300 seconds. ## [sub-underdog-v2] Underdog (v2) (docs: https://owlsinsight.com/docs#sub-underdog-v2) | Sports, league rules and the WebSocket event for Underdog: see "Source API book: Underdog (underdog)" in [source-api]. Underdog Fantasy's pick'em board (higher / lower player props), raw; out-of-season sports return count 0. data (null on no-data / partial-miss) carries over_under_lines[{ id, line_type, live_event, stat_value ("27.5", a string), status, options[{ choice ("higher" | "lower"), american_price, decimal_price, payout_multiplier, selection_header, selection_subheader }], over_under: { title, category, appearance_stat: { appearance_id, stat, display_stat } } }], games[{ id, sport_id, title, scheduled_at, status }], solo_games[] (tennis, pga, mma), appearances[{ id, match_id, match_type ("Game" | "SoloGame"), player_id, team_id }] and players[{ id, first_name, last_name, position_name, team_id }]. Prices are STRINGS. Join: line.over_under.appearance_stat.appearance_id -> appearances[].id, then player_id -> players[].id and match_id -> games[].id or solo_games[].id per match_type. meta.hash matches the ETag. The freshness bar is 90 seconds: the source holds the feed for 60 seconds and one refresh cycle sits on top. ## [sub-versus-v2] Versus (v2) (docs: https://owlsinsight.com/docs#sub-versus-v2) | Sports, league rules and the WebSocket event for Versus: see "Source API book: Versus (versus)" in [source-api]. Raw versus.es markets (a Spanish sportsbook; market names are Spanish, "Ganador" is the match winner). data is keyed by EVENT id (the versus node id); each event carries start_date (unix seconds), participant_home / participant_away { name } and markets[{ market_type ("H2HT"), name, results[{ name, odds: { dec, us, num_ref, den_ref } }] }] with every market and player prop the book publishes. dec is decimal x1000 (2150 = 2.15), us is American, num_ref / den_ref is fractional; 0 means suspended. versus-v2-update streams per-market deltas at poll cadence; subscribe with an array of league keys since every sport is multi-league. ## [deep-links] Deep links (docs: https://owlsinsight.com/docs#deep-links) event_link on a bookmaker object opens the game on the sportsbook's site. Carried on odds for Pinnacle, FanDuel, DraftKings, BetMGM, Caesars, Hard Rock, 1xBet, Kalshi and Polymarket; on props for Pinnacle, FanDuel, DraftKings, BetMGM and Caesars. Optional everywhere: odds.data.fanduel[0].bookmakers[0].event_link, props.data[0].books[0].event_link. ## [esports-api] Esports (docs: https://owlsinsight.com/docs#esports-api) Esports (cs2, valorant, lol, dota2) come three ways: (1) consensus, the esports keys on /api/v1/{sport}/odds in the OddsEvent shape and the esports-update event (subscribe with esports: true; 1xBet esports are part of this feed); (2) per-game Pinnacle realtime, /api/v1/{game}/realtime, the Pinnacle wire format inside data (matchups with participants, markets, prices, limits; American prices; no team normalization) with ?league= REQUIRED (BLAST, LCK, Champions, DreamLeague; 400 without it); (3) the per-game events cs2-realtime, lol-realtime, valorant-realtime and dota2-realtime, subscribed with esportsRealtime: { cs2: { league: "BLAST" } }, where the server skips clients that declared no league for a game. CS2 match history is in [history-api]; esports are not on schedule or results, use live scores. ## [props-api] Player props (docs: https://owlsinsight.com/docs#props-api) #### GET /api/v1/{sport}/injuries | Returns player injury designations for a sport from two independent sources, each attributed and separately timestamped, plus a derived consensus where the sources can be matched to the same player with confidence. | Access: All paying tiers | Sports: mlb, nfl | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, nba, ncaab, ncaaf, nhl, soccer, table_tennis, tennis, valorant, volleyball, wnba | Query: source (optional) - Limit to one feed by role label, primary or secondary. An unrecognised label returns 400 with the supported list. | Query: status (optional) - Comma separated designations, for example out,questionable. An unrecognised value returns 400 with the supported list rather than an empty result. | Query: team (optional) - Comma separated team names or abbreviations, for example Atlanta Falcons or ATL. Repeated parameters work too. An unrecognised team returns 400 rather than an empty result. | Query: includeActive (optional) - Set to true to include players every source considers available. Ignored when status is given, since an explicit status filter is authoritative. | Response: { success, sport, count, players: [{ playerKey, playerName, teamName, teamAbbreviation, position, reportAge: { newestSeconds, oldestSeconds, datedRecords, undatedRecords }, records: [{ source, playerName, position, teamName, teamAbbreviation, teamId, injuryStatus, statusLabel, listDays, seasonEnding, bodyPart, practiceParticipation, practiceDescription, depthChartOrder, reportedAt, reportAgeSeconds }], consensus: { injuryStatus, agreement, sources, idVerified, suffixJoin, listDaysConflict, stale, staleAfterSeconds } }], meta: { sources, conflicts, listDaysConflicts, suffixJoins, unresolvedIdentity, reportAgeSummary, timestamp } } | Note: Supported sports are nfl and mlb. Other sports return 404. | Note: Every player carries one record per source that covers them, with that source own status, detail and report time. Records are never merged into one another. | Note: record.source is a role label, either primary or secondary, and meta.sources is keyed the same way. It identifies which of the sport two feeds a record came from so you can tell them apart and prefer one consistently. It is stable if a feed is changed underneath it. | Note: consensus is present only when the two sources can be matched to the same player with confidence. When they disagree, consensus.agreement is conflict and consensus.injuryStatus is null, and both records are returned. | Note: Players whose identity could not be established are listed in meta.unresolvedIdentity and carry no consensus. That includes a player whose two spellings differ only by a suffix when nothing else corroborates that they are the same person, since adding a suffix to one of two players is how a league separates a shared name. | Note: consensus.listDaysConflict is true when both sources agree a player is on an injured list but disagree about its length, for example 10 days against 60. The designation still reads as agreeing, so check this flag when the length matters. | Note: meta.sources[source].ageSeconds is null when the age is unknown, and is never 0 as a placeholder. A source is marked stale when its age is unknown, when it is older than 30 minutes, or when it holds no records. | Note: meta.sources[source].checks names the validations that ran on that source own data. Checks are reported per source, never pooled, so one source never inherits another. | Note: Cleared and roster-move designations are excluded by default. Pass includeActive=true to receive them, or name them explicitly in status, which overrides the default view. | Note: There are two clocks and they answer different questions. meta.sources[source].ageSeconds is how long ago we fetched the feed. record.reportAgeSeconds is how long ago that source published the designation itself, which is the one that tells you whether a status is current. | Note: record.reportAgeSeconds is null when a source publishes no timestamp of its own, and is never 0 as a placeholder. player.reportAge counts how many of a player records could be dated and how many could not, so a partly dated player is visible as such. | Note: consensus.stale is true when every dated record behind a designation is older than consensus.staleAfterSeconds, false when at least one is inside it, and null when no verdict is possible. Null is never a substitute for false. | Note: There are two reasons stale can be null, and staleAfterSeconds tells them apart. A number beside a null stale means the rule exists but nothing carried a timestamp to judge against it. A null staleAfterSeconds means no rule applies at all, for one of four reasons: the designation is one whose currency does not decay, such as active or a status we could not map; the sources disagree about the designation; they disagree about the length of an injured list stint; or a season ending placement does not expire. The first of those covers most players on a response that includes cleared designations. | Note: The staleness window depends on the designation, not only the sport. A weekly football designation such as questionable lapses after 10 days because the injury report is reissued every game week, while a baseball injured-list placement is given its own stint length plus two weeks, and a season-ending placement is never marked stale. | Note: meta.reportAgeSummary describes this response. staleConsensus, freshConsensus, noVerdict and noConsensus partition the players returned and add up to count. playersWithNoDatedRecord, datedRecords and undatedRecords say how much of the whole response could be dated, and are not scoped to any of those four buckets. | Note: A source that publishes no timestamps of its own yields no staleness verdict for a player only it covers, so filtering with source can return a page on which every consensus.stale is null. | Note: Records are retained by the source after a season ends, so check reportedAt and reportAgeSeconds on each record. | Note: 503 until the first collection has completed. #### GET /api/v1/{sport}/props | Returns the merged player props board for a sport: every game with its props grouped per sportsbook, with each book's freshness reported in meta. | Access: Rookie and above | Sports: cs2, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Query: game_id (optional) - Restrict the board to one game. | Query: player (optional) - Filter by player name: a partial match that ignores case, accents and apostrophes, reads other punctuation as a space and joins initials, so RJ Harvey finds R. J Harvey. | Query: category (optional) - Filter by prop category. Betting-name aliases are accepted too: anytime_touchdown, anytime-touchdown, anytime_td, anytime-td, atd and touchdown_scorer for touchdowns; first_td and first-td for first_touchdown_scorer. An unknown category returns 400 with valid_categories and aliases. Only this merged board accepts the aliases; /props/{book} takes category names only. [values: any prop category key; the per-sport lists are under "Prop categories by sport" in [props-api]] | Query: state (optional) - Two-letter FanDuel state code the feed reads (ks, co) whose marketIds to serve on FanDuel props and alternate lines; meta.fanduelMarketIds reports the state, how many ids were mapped, how many were omitted and when the map was last refreshed. FanDuel issues a different marketId in every state while selectionId is the same everywhere, so a link built with another state's marketId fails on the slip. Default nj, the feed's own ids. A FanDuel market with no id for the state has its marketId omitted rather than served as another state's; a state the feed does not read is a 404; a code that is not two letters is a 400. REST only: the props WebSocket events carry the New Jersey ids. | Query: books (optional) - Comma-separated sportsbook keys to include; also accepted as book. Defaults to every props book. 400 when none of the keys listed under books is valid; under book a list with no valid key returns every book and is reported in meta.ignored_params. bet365 and polymarket are still accepted but return nothing here: bet365 props are on /api/v2/bet365/{sport}, and meta.retiredBooks says so when you ask for one. [values: pinnacle, fanduel, draftkings, caesars, betmgm, kalshi, novig] | Response: { success, data: [{ ...game, books: [{ book, props: [...] }] }], meta: { sport, timestamp, propsReturned, gamesReturned, requestedBooks, availableBooks, booksReturned, books: { : { lastUpdate, ageSeconds, status } }, fanduelMarketIds?: { state, mapped, unmapped, timestamp }, retiredBooks?, ignored_params?, ignored_reasons? } } | Note: Query parameters this endpoint does not read are reported in meta.ignored_params rather than rejected; a value the endpoint read but could not apply (an unknown market, a comma list where one value is allowed, a book alias with no valid key) is reported too, with the reason in meta.ignored_reasons. | Note: Unknown book keys inside books are dropped and the applied list is meta.requestedBooks. | Note: meta.books reports each requested book with lastUpdate, ageSeconds and a status of ok, stale or no-data; ageSeconds is the time since that book last published, not the age of its prices. | Note: There is no aggregate status on purpose: a book whose feed stops disappears from booksReturned instead of ageing, so a roll-up would read ok for a dead book. | Note: availableBooks is the static catalogue; booksReturned is what contributed to this response. | Note: marketStructure is present only when the book published one (over_under, milestone, single_sided or pick_one). Absent means unknown, never over_under: on an integer line, over 20 and 20+ disagree at exactly 20. | Note: Kalshi props quote the takeable price: overPrice and underPrice are the ask a buyer pays now, overPriceMid and underPriceMid are the bid/ask midpoint, and overPriceBasis and underPriceBasis say per side whether the served price is takeable or, when no ask was resting, midpoint. A midpoint is a fair-value estimate, not a price anyone can trade at. These four fields appear on Kalshi props only; every other book's posted price is takeable by construction. | SDK: ts getProps() / py get_props() #### GET /api/v1/{sport}/props/{book} | Returns one sportsbook's player props for a sport, in that book's own line format. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, soccer, table_tennis, tennis, valorant, volleyball | Books: betmgm, caesars, draftkings, fanduel | Sports for betmgm: mlb, nba, ncaab, ncaaf, nfl, nhl | Sports for caesars: mlb, nba, ncaab, ncaaf, nfl, nhl | Sports for draftkings: mlb, nba, ncaab, ncaaf, nfl, nhl | Sports for fanduel: mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Query: game_id (optional) - Restrict the response to one game. | Query: category (optional) - Filter by prop category. Each book validates against its own category vocabulary: an unknown category returns 400, a known category the book does not price returns an empty result. Betting-name aliases such as anytime_touchdown are not accepted here: send the category name (touchdowns), or use the merged /props board, which accepts them. | Query: state (optional) - fanduel only. Two-letter FanDuel state code the feed reads (ks, co) whose marketIds to serve on every prop and alternate line; meta.marketIds reports the state, how many ids were mapped, how many were omitted and when the map was last refreshed. FanDuel issues a different marketId in every state while selectionId is the same everywhere, so a link built with another state's marketId fails on the slip. Default nj, the feed's own ids. A market with no id for the state has its marketId omitted rather than served as another state's; a state the feed does not read is a 404; a code that is not two letters is a 400. REST only: fanduel-props-update carries the New Jersey ids. | Query: player - as on GET /api/v1/{sport}/props | Response: { success, data: [games], meta: { sport, book, timestamp, gamesReturned, propsReturned, filters?, message?, marketIds?: { state, mapped, unmapped, timestamp } } } | Note: BetMGM returns a flat props array per game keyed by fixtureId; the other books wrap props in a books array like the merged /props board. | Note: FanDuel props mix over/under markets (both prices) with yes-only markets such as anytime touchdown or goal scorer, first goal scorer, double-double and N+ milestones (underPrice null). An N+ market is served at its over line, N - 0.5, both as the main line and as an alternateLines rung: 1+ is 0.5, 2+ made threes or touchdowns is 1.5, 175+ yards is 174.5. alternateLines hold the player's other lines; a rung equal to the main line is not repeated there. | Note: Each FanDuel prop carries FanDuel's own marketId plus the selection id (overSelectionId/underSelectionId on the main line; each alternateLines rung carries its own marketId and selectionId). The pair builds a FanDuel bet-slip link, addToBetslip?marketId=&selectionId=. FanDuel issues a different marketId per state while the selectionId is the same everywhere, so use the marketId for the region your link targets. | Note: Bet365 player props are retired from this route; Bet365 raw markets are served by its v2 source endpoint. | SDK: ts getBookProps() / py get_book_props() #### GET /api/v1/{sport}/props/{book} (RETIRED, HTTP 410) | RETIRED (HTTP 410). Bet365 v1 props have been retired. Bet365 v2 (raw markets, including player props) covers nba, mlb, nhl, soccer, and nfl at /api/v2/bet365/{sport}; bet365 v2 ncaaf has game lines only (bet365 lists no player props for it), and no other sport is covered by bet365 v2. | Sports it served: ncaab, ncaaf | Books it served: bet365 #### GET /api/v1/{sport}/props/{book} (RETIRED, HTTP 410) | RETIRED (HTTP 410). Replacement: /api/v2/bet365/{sport} | Sports it served: mlb, nba, nfl, nhl | Books it served: bet365 #### GET /api/v1/{sport}/props/{book}/history | Returns the line movement of one player prop at the sportsbook named in the path: the opening line and every recorded change in line and price for a game, player and category. | Access: Rookie and above | Sports: cs2, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cricket, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Books: bet365, betmgm, caesars, draftkings, fanduel, kalshi, novig, pinnacle, polymarket | Query: game_id (required) - Game identifier, as returned in the props board (for example nba:BOS@SAC-20260102). | Query: player (required) - Player name exactly as it appears in the props board. | Query: category (required) - Prop category to trace. [values: any prop category key; the per-sport lists are under "Prop categories by sport" in [props-api]] | Query: hours (optional) - Look back this many hours (1 to 168) instead of starting at the opening line. | Response: { success, data: { player, category, book, sport, timeRange: { start, end, mode, durationHours }, openingLine: { line, overPrice, underPrice, timestamp, timestampUtc } | null, history: [{ timestamp, timestampUtc, line, overPrice, underPrice }], dataPoints } } | Note: Without hours the window starts at that book's opening line for the prop. | Note: Equivalent to /props/history?book=; the SDK methods take the book as a parameter. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | Note: timestamp is the capture time as stored: fanduel and caesars rows stored before 2026-09-16 06:57:52 hold US Eastern wall time despite the Z. timestampUtc is the capture time in UTC, null where the clock is unknown (rows stored before 2026-03-28, and draftkings rows from 2026-03-28 04:00 to 2026-04-10). Time filters read timestamp. | SDK: ts getPropsHistory() / py get_props_history() #### GET /api/v1/{sport}/props/history | Returns the line movement of one player prop: the opening line and every recorded change in line and price for a game, player and category. Defaults to Pinnacle; pass book to trace another sportsbook. | Access: Rookie and above | Sports: cs2, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cricket, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Query: book (optional) - Sportsbook whose line to trace. Defaults to pinnacle. [values: pinnacle, fanduel, draftkings, caesars, betmgm, kalshi, novig] | Query: game_id, player, category, hours - as on GET /api/v1/{sport}/props/{book}/history | Response: { success, data: { player, category, book, sport, timeRange: { start, end, mode, durationHours }, openingLine: { line, overPrice, underPrice, timestamp, timestampUtc } | null, history: [{ timestamp, timestampUtc, line, overPrice, underPrice }], dataPoints } } | Note: Without hours the window starts at that book's opening line for the prop. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | Note: timestamp is the capture time as stored: fanduel and caesars rows stored before 2026-09-16 06:57:52 hold US Eastern wall time despite the Z. timestampUtc is the capture time in UTC, null where the clock is unknown (rows stored before 2026-03-28, and draftkings rows from 2026-03-28 04:00 to 2026-04-10). Time filters read timestamp. | SDK: ts getPropsHistory() / py get_props_history() #### GET /api/v1/{sport}/props/results | Returns graded player props for one completed game: what each player actually recorded and the verdict of every closing book line against it. With date instead of game_id it lists the graded games for a day so game ids can be discovered. Anything that could not be graded carries a reason instead of a guess. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, soccer, table_tennis, tennis, valorant, volleyball | Query: game_id (optional) - Game identifier for one game's board. Required unless date is given. Must belong to the sport in the path; a mismatch returns 400 naming the right endpoint. | Query: date (optional) - Calendar day, YYYY-MM-DD. Lists the graded games for that day with per-game entry and player counts, so game ids can be discovered. Ignored when game_id is given, and the player, category and confirmed_only filters do not apply in this mode. Dates follow the date in the game id: the US Eastern date for MLB games from 2026-10-11, the UTC start date otherwise (so a late-evening Pacific game in another sport, or an MLB game before 2026-10-11, lists under the next day). Ungraded games are absent, not empty. 400 for a value that is not a real calendar date. | Query: category (optional) - Restrict to one prop category. [values: any prop category key; the per-sport lists are under "Prop categories by sport" in [props-api]] | Query: player (optional) - Restrict to one player: a partial match on the stored name, with the same case, accent and punctuation rules as /props. | Query: confirmed_only (optional) - Set to true to return only confirmed grades. By default provisional grades are included and each carries final: false. A grade is confirmed when the box score, read again after the window in which scorers revise it, still agrees: about 90 minutes after grading, or about 36 hours for NBA, where the league posts stat corrections the day after. A game first graded more than a day after it started (36 hours for NBA), when older games are added, is confirmed on that one read. | Response: With game_id: { success, data: [{ playerName, playerId, category, actual, status, final, statSource, sourceRef, gradedAt, ungradableReason, lines: [{ book, line, overPrice, underPrice, capturePhase, verdict, ungradableReason }] }], meta: { mode: "game", sport, gameId, playersReturned, entriesReturned, linesGraded, lineOutcomes, entryStatuses, provisional, confirmedOnly, capturePhases, actualsTruncated, linesTruncated, unmatchedLineGroups, unmatchedLineExamples, absentCategoryGroups, unsupportedCategoryGroups, unsupportedCategoryExamples, nameVariantJoins (these three filled for NBA only; 0, [] and [] for other sports), ambiguousPlayers, suffixGaps, checks, timestamp, message? } }. With date: { success, data: [{ gameId, entries, players, finalEntries, firstGradedAt, lastGradedAt }], meta: { mode: "date", sport, date, gamesReturned, gamesTruncated, note, timestamp } } | Note: Pass game_id or date; with neither the response is 400. meta.mode says which shape came back. | Note: status on each entry is ok, dnp, no_stats, unmapped_category or stat_not_reported. An entry or line that could not be graded carries ungradableReason: dnp (the bet voids), no_stats (no box-score row matched, never treated as a void), stat_not_reported, unmapped_category, ungradable_structure (an integer line whose over/under versus N+ meaning is unknown), ambiguous_player (a display name shared by two players in the game, so no line is joined) or no_actual_value. | Note: verdict on a line is over, under or push for an over/under market and hit or miss for a milestone (N+) market. A book's market structure is honoured when it published one; an absent structure is unknown and is never defaulted to over/under. | Note: capturePhase archive marks the last in-play line, captured at game end because the pre-game close was missed. It is not a closing line, and a hit rate built on it is not comparable to one built on commence lines. | Note: Provisional grades are included by default and every entry carries final; meta.provisional counts them. Box scores are revised for a short window after a game, so confirmed_only=true trades some delay for a grade read after that window. A stat correction published later can still change a confirmed grade. | Note: meta.lineOutcomes counts verdicts per book line; meta.entryStatuses counts entries per player and category. They have different denominators, so a hit rate built from lineOutcomes is weighted by how many books quoted each player. | Note: Grading is enabled sport by sport and runs after the game is final and the box score is published. Until then, or on a sport where grading is not enabled, data is empty and meta.message says so. | Note: meta.checks lists exactly what was evaluated. The response never claims a game is fully graded. | SDK: ts getPropResults() / py get_prop_results() #### GET /api/v1/{sport}/props/trends | Returns a player's recent form for one prop category: the actual value from each recent game and, when a line is supplied, the hit rate, streak and average measured against it. The line can be a number, or closing to grade each game against the line a named sportsbook had captured at that game's start. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, soccer, table_tennis, tennis, valorant, volleyball | Query: player (required) - Player name. Matched by normalized key against stored names, so punctuation, case and suffixes such as Jr. do not matter. 400 when fewer than three letters or digits remain after normalization; 409 when the name covers two different players. | Query: category (required) - Prop category. [values: any prop category key; the per-sport lists are under "Prop categories by sport" in [props-api]] | Query: line (optional) - Line to measure every game against: a number, or the literal closing to grade each game against the line the sportsbook in book had captured at that game's start. Without it the actuals are returned but no hit rate is computed. | Query: book (optional) - Sportsbook key whose captured closing line to grade against. Required with line=closing (400 without it); ignored otherwise. A book with no captured close for a game leaves that game as no_closing_line. | Query: window (optional) - Number of recent games to include, 1 to 50. Default 10. | Query: confirmed_only - as on GET /api/v1/{sport}/props/results | Response: { success, data: { player, category, line, book?, gamesReturned, average, overRate, overs, unders, pushes, decided, streak: { side, length } | null, games: [{ eventId, gameDate, actual, status, final, verdict, ungradableReason, closing?: [{ line, isMain, verdict, ungradableReason }] }] }, meta: { sport, window, confirmedOnly, closingPhase?, closingTruncated?, provisional, gamesAvailable, gamesAvailableCapped, matchedNames, ungradable, checks, note?, timestamp } } | Note: 409 with matchedNames in the body when the name covers two different players, rather than pooling them into one trend. On a 200, meta.matchedNames lists the stored spellings the games came from; more than one means two spellings normalized to the same key, so check them before trusting the aggregate. | Note: overRate is null, not 0, when no game in the window resolved. decided is its denominator and excludes pushes and unresolved games. streak runs from the most recent game and stops at the first unresolved game. | Note: A numeric line is graded as over/under on every game, including integer lines, because a caller-supplied threshold is unambiguous. With line=closing each game is graded against the main line the book had captured at game start; every captured rung is listed under closing with isMain per rung, a game with no main rung is no_closing_line and one with several is multiple_closing_lines. A line captured in play is never treated as a close; meta.closingPhase records that only game-start captures were used. | Note: meta.closingTruncated true means the rung fetch hit its cap and games past the cut read as no_closing_line; re-query with a smaller window. meta.gamesAvailable is a floor, not a count, when meta.gamesAvailableCapped is true. | Note: Provisional grades are included by default; each game carries final and meta.provisional counts them. Grading is enabled sport by sport, so a player on a sport where it is not enabled has no games to return. | SDK: ts getPropTrends() / py get_prop_trends() #### GET /api/v1/props/{book}/stats | Returns summary statistics for one sportsbook's player props feed: what is held per sport and when it was last refreshed. | Access: Rookie and above | Books: betmgm, caesars, draftkings, fanduel | Response: { success, data: { ... } } | SDK: ts getBookPropsStats() / py get_book_props_stats() #### GET /api/v1/props/{book}/stats (RETIRED, HTTP 410) | RETIRED (HTTP 410). Bet365 v1 props (and their stats endpoint) have been retired. Bet365 raw markets are at /api/v2/bet365/{nba|mlb|nhl|soccer|nfl|ncaaf}. | Books it served: bet365 #### GET /api/v1/props/stats | Returns summary statistics for the merged player props feed: what is held, per sport and book, and when it was last refreshed. | Access: Rookie and above | Query, response and notes: identical to GET /api/v1/props/{book}/stats | SDK: ts getPropsStats() / py get_props_stats() | Prop categories by sport: | cs2: (none) | mlb: combos, doubles, earned_runs, extra_base_hits, hits, hits_allowed, hits_runs_rbis, hits_runs_stolen_bases, hits_stolen_bases, hits_walks_stolen_bases, home_runs, outs_recorded, rbis, runs, runs_rbis, singles, stolen_bases, strikeouts_batter, strikeouts_pitcher, total_bases, triples, walks, walks_allowed | nba: assists, blocks, combos, double_double, minutes, points, pts_asts, pts_rebs, pts_rebs_asts, rebounds, rebs_asts, steals, threes_made, triple_double, turnovers | ncaab: assists, blocks, combos, double_double, minutes, points, pts_asts, pts_rebs, pts_rebs_asts, rebounds, rebs_asts, steals, threes_made, triple_double, turnovers | ncaaf: assisted_tackles, combos, defense_touchdowns, extra_points_made, field_goals_made, first_touchdown_scorer, interceptions, kicking_points, longest_completion, longest_reception, longest_rush, most_receiving_yards, most_rushing_yards, pass_attempts, pass_completions, pass_rush_yards, passing_tds, passing_tds_1h, passing_tds_1q, passing_yards, passing_yards_1h, passing_yards_1q, receiving_tds, receiving_yards, receiving_yards_1h, receiving_yards_1q, receptions, rush_attempts, rush_rec_yards, rushing_tds, rushing_yards, rushing_yards_1h, rushing_yards_1q, sacks, tackles, tackles_assists, touchdowns, touchdowns_1h, touchdowns_1q, touchdowns_2h, touchdowns_2q | nfl: assisted_tackles, combos, defense_touchdowns, extra_points_made, field_goals_made, first_touchdown_scorer, interceptions, kicking_points, longest_completion, longest_reception, longest_rush, most_receiving_yards, most_rushing_yards, pass_attempts, pass_completions, pass_rush_yards, passing_tds, passing_tds_1h, passing_tds_1q, passing_yards, passing_yards_1h, passing_yards_1q, receiving_tds, receiving_yards, receiving_yards_1h, receiving_yards_1q, receptions, rush_attempts, rush_rec_yards, rushing_tds, rushing_yards, rushing_yards_1h, rushing_yards_1q, sacks, tackles, tackles_assists, touchdowns, touchdowns_1h, touchdowns_1q, touchdowns_2h, touchdowns_2q | nhl: blocked_shots, combos, first_goal_scorer, goals, goals_against, hockey_assists, hockey_points, power_play_points, saves, shots_on_goal | soccer: assists, combos, corners_taken, first_goal_scorer, fouls_committed, goals, goals_assists, passes, saves, shots_on_target, tackles | tennis: games_won | wnba: assists, blocks, combos, double_double, minutes, points, pts_asts, pts_rebs, pts_rebs_asts, rebounds, rebs_asts, steals, threes_made, triple_double, turnovers The "Sports for " lines above are the authoritative answer to "does book X have props for sport Y on /api/v1/{sport}/props/{book}". Source API books carry their own player props in their native data where their section says so; see [source-api]. The "Prop categories by sport" lists are the union of categories our feeds can map for each sport, not a promise of what is quoted: a given book serves a subset on a given day, so a listed category can come back empty. The merged /api/v1/{sport}/props board also accepts betting-name aliases in ?category= (anytime_touchdown for touchdowns, first_td for first_touchdown_scorer; its category Query line lists every alias), while /api/v1/{sport}/props/{book} takes category names only. These categories are not the set the SGP builder can price; for that see [sub-sgp]. Each game carries a books array; exchange and prediction-market props (kalshi, novig) add a liquidity object { amount?, volume24h?, volume?, openInterest?, bidSize?, askSize? } that sportsbook props lack. Streaming is per book: subscribe-props for Pinnacle (player-props-update) or the book's own subscribe event (subscribe-fanduel-props -> fanduel-props-update), acknowledged with -props-subscribed. Closing lines for past games are the backfilled GET /api/v1/history/player-props ([history-api]); /props/history traces movement on the live slate. meta.booksReturned is the list of books that actually contributed to the response and meta.books gives each requested book's feed age and status; read those, not availableBooks (the static catalogue), to tell a quiet book from an absent one. Exchange props carry the price a taker actually pays. For kalshi, overPrice and underPrice are the venue's ask, overPriceBasis and underPriceBasis say per side whether that price is takeable or a midpoint fallback on a one-sided book, and the midpoint is kept as overPriceMid and underPriceMid for fair-value work. A midpoint compared against a sportsbook line shows an edge that cannot be taken, which is why the ask is the served price. Results (/api/v1/{sport}/props/results) settle a completed game. The API stores what each player actually recorded, one entry per player and category, and grades every book line it captured at that game's start against it at read time, so a verdict can never drift from the actual it came from. A verdict exists only where it can be defended: a line is graded when the market's over/under or N+ meaning is known, a player who did not appear voids as dnp, and a player the box score did not resolve is reported as no_stats, which is the API's own gap and never a void. Two players who share a display name are refused as ambiguous_player rather than one player's stats settling the other's bets. Every entry carries final. A first grade is written provisional because scorers revise box scores for a while after the last out; a later re-read that agrees promotes it, and confirmed_only=true waits for that promotion. Start with ?date= to see which games have graded, then read one game by its id. Trends (/api/v1/{sport}/props/trends) turn those actuals into recent form for one player and category: the last N games with each actual and, given a line, the hit rate, average and streak measured against it. The denominator is only the games that decided; pushes and games that could not be graded are left out, and a window in which nothing decided reports overRate null rather than a fabricated 0. A number is your own threshold and is graded as over/under on every game. line=closing with a book grades each game against the main line that book had captured at that game's start; alternate rungs are reported under closing and never decide, a game with no captured close for that book is no_closing_line, and a game first seen in play carries only an in-play line, which is excluded for the same reason. A name that covers two different players is refused with 409 rather than averaged into one line of form. Both endpoints are on the props tiers and are additive: nothing on /props changed shape. SDK: client.rest.getPropResults() and client.rest.getPropTrends() in TypeScript; get_prop_results and get_prop_trends in Python, with the same parameters. ## [sub-sgp] Same Game Parlay (docs: https://owlsinsight.com/docs#sub-sgp) #### POST /api/v1/{sport}/sgp/batch | Prices several same game parlays in one call: each on one book (mode book) or each on every same game parlay book (mode compare). Each book's answer is exactly what /sgp/build returns for that book and parlay. | Access: MVP and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Body: A JSON object with mode ('book' or 'compare'), parlays (an array of { ref?, eventId, legs }: eventId is our event id for the game, as /api/v1/{sport}/odds gives it, legs are the legs /sgp/build takes, and ref is an optional label up to 100 characters returned with the result; at most 10 parlays in mode book and 4 in mode compare), book (mode book: the one book to price every parlay on), and optionally books (mode compare: the books to ask; default every book), deadlineMs (how long each parlay waits for its books, 1000 to 15000; default 6000), fanduel ('cold' or 'skip', as on /sgp/compare), fair (boolean: add our fair-price estimate to each parlay) and fresh (boolean: never use a cached quote). | Response: { success, sport, mode, results: [in request order; mode book: { index, ref?, eventId, legsHash, status, book, bookStatus, bookEventId?, httpStatus?, result?: , cached?, reason?, fair? }; mode compare: { index, ref?, eventId, legsHash, status, best, books: [], fair? }], meta: { timestamp, deadlineMs, parlays, quotesCached, quotesMade, quotesPriced } } | Note: Parlays are priced one after the other, in request order; in mode compare the books price each parlay side by side, as on /sgp/compare. A parlay whose pricing would begin more than about 20 seconds after the call began has status not-started and was not priced, except for any book answered from a cached quote, and carries no fair block: send it again. A slow book prices fewer parlays in that time (FanDuel takes about 5 to 15 seconds a parlay). Every other parlay has status done. bookStatus (mode book) and books[].status (mode compare) take the values /sgp/compare uses: priced, refused, unresolved, busy, no-price, timeout, event-not-listed, ambiguous-event, unsupported, disabled or skipped. | Note: A parlay repeated in one batch (same eventId, same legs in the same order) is priced once and reported for each copy. | Note: Before a game starts, a book that priced exactly these legs for this game in the last 15 seconds may be answered from that quote, as on /sgp/compare: cached is true, result.meta.cached is true and result.meta.cacheAgeMs is the quote's age when the response was sent; result.meta.pricedAt is still when the book priced it. A cached answer costs no build. Send fresh: true to price everything now. A game that starts within a minute is always priced fresh. | Note: Every quote not answered from a cached quote counts one build against your per-minute SGP build limit (one per parlay in mode book, one per book and parlay in mode compare), reserved for the whole batch before any book is priced. A quote that never reaches the book (not listed, a different game, a leg that does not resolve, no time left, or a parlay not started) is given back at once while the minute it was counted in is still running. Over the limit the call is a 429 SGP_RATE_LIMIT with unitsNeeded and a Retry-After header; a batch that needs more builds than your limit allows at all is a 429 SGP_BATCH_TOO_LARGE with unitsNeeded and no Retry-After. A batch counts as one request against your plan rate limit. | Note: In mode book, a book outside the SGP books is a 400 SGP_BOOK_UNSUPPORTED, a book that does not price the sport a 400 SGP_BOOK_SPORT_UNSUPPORTED, and a book whose pricing is paused a 503 SGP_BOOK_DISABLED, as on /sgp/build. | Note: MVP and Hall of Fame plans. On a lower plan the call is a 403 with code SGP_TIER_REQUIRED; a 503 with code SGP_FEATURE_DISABLED means batches are not switched on yet. | SDK: ts batchSgp() / py batch_sgp() #### POST /api/v1/{sport}/sgp/build | Prices a same game parlay on the book named in book (FanDuel, DraftKings, Hard Rock, BetRivers, Bally Bet, BetMGM and Caesars): resolves each leg to that book's own selection for one game and returns that book's own correlation-adjusted same game parlay price for exactly those legs, verified leg by leg, not a naive multiplication of the singles. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Body: A JSON object with eventId (the book's own event id from /sgp/events, sent as a string; FanDuel lists it as a number, DraftKings, Hard Rock, BetRivers and Bally Bet as strings of digits, BetMGM as its fixture id: digits (NBA, NHL) or digits:digits (NFL, for example 6:43680), Caesars as its event id, a UUID), book (required, one of fanduel, hardrock, draftkings, betrivers, ballybet, betmgm, caesars; a missing book is a 400, and the books the builder prices are listed in supportedBooks on a 400 SGP_BOOK_UNSUPPORTED) and legs, an array of 1 to 12 legs. Every leg has type, one of moneyline, spread, total, team_total or player_prop. moneyline, spread and total legs take selection (the team, or over/under for a total) and, for spread and total, a numeric point. team_total legs (one team's own total) take team, selection (over or under) and a numeric point. On NHL, a moneyline, spread or total leg may also take period (P1, P2 or P3) for one period instead of the full game; a period moneyline is the period's 3-way result, so its selection is a team or Tie. player_prop legs take player, category, side (over or under) and a numeric line. At least two legs must resolve for a price to be returned. Optional fair (boolean, MVP and Hall of Fame plans): true adds our fair-price estimate in its own fair block beside the book's price. | Response: { success, pricing: "-slip", sgp: { american, decimal, isSGM, legs }, legs: [{ ...leg, resolved, description, fdSelection?: { marketId, selectionId }, bookSelection?: { marketId, selectionId }, single: { american, decimal } | null }], meta: { eventId, book, matchup, commence_time, timestamp, resolvedAt, catalogAgeMs?, priceRequestedAt, pricedAt, legsHash, lineUnverified?, lineUnverifiedLegs? }, fair?: { estimate: true, label, price: { probability, decimal, american } | null, why, booksDisagree, basis: { legs, correlation, correlationFactor, correlationSources: [{ book, factor }], marginAdjusted }, coverage: { legs, withFair, missingLegs }, legs: [{ legIndex, probability, source, line, ageMs, booksDisagree, why, pinnacleLine? }], independent: { probability, decimal, american } | null, computedAt } }. A refused or unavailable build's body is { success: false, error, message, reasonCode, code, failureCodes?, legIndex?, lineEchoed?, legs, meta } (a 422 with the reasonCode, or a 503 with reasonCode no-price). A leg-resolution 422 body is { success: false, error, reasonCode: "unresolved", code: "unresolved", legs, meta }. | Note: The price is the named book's own same game parlay price for exactly the legs sent, at the lines they resolved to, verified leg by leg: it is returned only when the book's answer confirms all of that; otherwise the call fails rather than returning a price. On a 200, sgp.isSGM is always true. A book whose pricing misbehaves may be paused: its builds are then a 503 SGP_BOOK_DISABLED until it is switched back on, and the other books are unaffected. | Note: A resolved leg carries the book's own ids: fdSelection { marketId, selectionId } on FanDuel, bookSelection { marketId, selectionId } on every other book (DraftKings: its market id and selection id; Hard Rock: its market id and selection id, both strings of digits; BetRivers and Bally Bet: the book's offer id and outcome id, both strings of digits; BetMGM: marketId v1:: or v2::, which names the BetMGM platform, fixture and market the pick is sent with, and selectionId its option id; on NHL, marketId sc:: and selectionId the option string BetMGM's bet builder lists for it; Caesars: its market id and selection id, both UUIDs). Only one of the two is present on a leg. | Note: FanDuel prices are FanDuel's own bet-slip price, quoted from FanDuel's New Jersey sportsbook. FanDuel runs separate markets in other states and in Ontario, so a slip placed there can show a different parlay price for the same legs. The price also moves with the market, so compare it only with a slip taken at the time in meta.pricedAt. | Note: fdSelection.marketId is FanDuel New Jersey's id for that market. FanDuel issues a different marketId in every other state and in Ontario, while selectionId is the same everywhere, so to place a leg outside New Jersey use your region's marketId for that selectionId. | Note: meta.timestamp and meta.resolvedAt are the same instant: when the legs were resolved against the book's catalog, BEFORE pricing. meta.catalogAgeMs is the MINIMUM age that catalog data already had then; the true age can be higher. meta.priceRequestedAt is when the price request went out and meta.pricedAt is when the book's answer came back, both on our server's clock. Each can read slightly LATER than the real moment, by the time the answer took to reach our server (typically under 100 ms). If that timing detail is unavailable, priceRequestedAt is when pricing started (earlier than the request) and pricedAt is when the answer reached our server, so the two still enclose the quote. pricedAt is never earlier than priceRequestedAt and is the quote time to use. They are present on every 200, and on a refused 422 or a no-price 503 only when the book returned a response. catalogAgeMs is omitted when the age is unknown. | Note: 422 with reasonCode "unresolved" (code repeats it) when any leg cannot be resolved on the book, including a selection the book has suspended; the legs array says which leg and why. Nothing was priced. | Note: 422 also when two legs resolve to the same selection on the book, and on FanDuel for player props whose line is carried only in the selection name, because FanDuel does not confirm that line in its price response. These are refused before pricing. | Note: On FanDuel, alternate N-or-more strikeout rungs are priced, but its price response identifies the rung by its selection only and does not confirm its line. A 200 that includes one carries meta.lineUnverified: true and meta.lineUnverifiedLegs, the indexes of those legs in your legs array. On BetMGM NHL, the price response names no leg at all, so every NHL leg is listed there. Both keys are absent when every line was confirmed, and never appear on a DraftKings, Hard Rock, BetRivers, Bally Bet or Caesars build, or a BetMGM NFL or NBA build, because each confirms every line. | Note: Player prop coverage is not uniform across books, sports and categories. The categories a player_prop leg can use on FanDuel, per sport: mlb: strikeouts, hits, total-bases, home-runs, rbis, runs, stolen-bases, singles, doubles, triples, hits-runs-rbis. nba: points, rebounds, assists, threes, and accepted: steals, blocks. wnba: points, rebounds, and accepted: assists, threes, steals, blocks. nfl: touchdowns, passing_yards, rushing_yards, receiving_yards, receptions, passing_tds, and accepted: first_touchdown_scorer. ncaaf: touchdowns, passing_yards, rushing_yards, receiving_yards, receptions, passing_tds. nhl: goals, hockey_points, hockey_assists, shots_on_goal, first_goal_scorer. ncaab, soccer and tennis have no player prop categories. On every book, an accepted category prices when the book posts that market for the player in the game, and the leg is refused before pricing, with the reason in legs[].reason, when it has not; a player_prop leg outside the book's list is refused with a 422 and a specific reason before pricing, and so is a game-leg type outside the book's game legs. Football, basketball and hockey categories use the /props category names (receptions, points, hockey_points; for NHL, points and assists are accepted too), except basketball threes, which /props calls threes_made; baseball categories use the builder's MLB names (strikeouts, total-bases). Game legs per sport: mlb, nba, wnba, nfl, ncaaf and ncaab: moneyline, spread and total; nhl: moneyline, spread, total and team_total; total also for one period (period P1, P2 or P3); soccer and tennis: moneyline only. | Note: Spread and total legs price at the book's main line. On NHL, Hard Rock, FanDuel, Bally Bet and, before the game, DraftKings also price an alternate puck line or total they list: send the point you want, and a point the book does not list is refused naming the lines it lists. team_total legs price on Hard Rock and FanDuel NHL only. Every other book and sport refuses an alternate point (Hard Rock, DraftKings, BetMGM, BetRivers and Bally Bet name their main line in the reason; FanDuel outside NHL names only the point it could not find), and refuses a team_total leg as a leg type it does not price. Alternate lines and team_total legs can be switched off: while they are, an alternate point is refused naming the book's main line (FanDuel's names only the point), and a team_total leg is a 400 as an unknown leg type. | Note: A leg with a period (P1, P2 or P3, NHL) prices on Hard Rock, and on FanDuel for a period total only: on Hard Rock a period moneyline is that period's 3-way result (a team, or Tie for a tied period), and a period spread or total prices at a line Hard Rock lists for that period (before the game, Hard Rock lists a total and a spread for the 1st period); FanDuel prices a period total at its 1st period main total or at an alternate total it lists for that period, and refuses a period moneyline or spread. Every other book refuses a leg with a period as unsupported; no book ever prices it as the full game. Period legs can be switched off, and while they are, a leg with a period is refused the same way. There is no regulation-time leg: Hard Rock does not combine its regulation result with other legs. A period that is not P1, P2 or P3, a period on a sport other than NHL, and a period on a team_total or player_prop leg are a 400. | Note: fair: true (MVP and Hall of Fame plans) adds fair, our estimate of the parlay's price built from de-vigged leg probabilities. It is never the book's price: sgp is unchanged, and fair.estimate is always true. Each leg's probability is our Fair v1 fair: for moneylines, spreads and totals the fair /api/v1/{sport}/fair serves (a spread or total only at that fair's own number, never a whole number), for player props the fair /api/v1/{sport}/props/ev scores against (x.5 lines). Each leg's source says whether Pinnacle anchored it or, for a moneyline, at least three books agreed; booksDisagree flags a leg other books price differently. The legs' probabilities are multiplied and adjusted by how the book itself priced these legs together: basis.correlationFactor is the product of the book's single prices divided by its parlay price. That factor also keeps the book's parlay surcharge over its singles (basis.marginAdjusted is false), so the estimate removes the singles' vig, not that surcharge. fair.price is null, with fair.why, when any leg has no fair (coverage.missingLegs and each leg's why say which, for example a line off the fair's number or a fair board too old to use), when the book returned no single price for a leg (BetMGM NHL returns none) or a factor outside 0.25 to 4, once the game has started, or when the fair could not be worked out in time. fair.independent is the same legs treated as unrelated. fair is added to priced 200s only. On a Rookie plan, fair: true is a 403 with code SGP_TIER_REQUIRED, sent before anything is priced and not counted against any limit; a 503 with code SGP_FEATURE_DISABLED means the fair price is not switched on yet. | Note: DraftKings prices are DraftKings' own same game parlay pricing, quoted from DraftKings New York (DraftKings runs separate markets in other states); the betslip may differ during a price move, so compare only with a slip taken at the time in meta.pricedAt. A resolved DraftKings leg carries bookSelection: { marketId, selectionId }, DraftKings' own ids; the eventId is DraftKings' numeric event id from /sgp/events?book=draftkings. Every spread, total and player-prop line is confirmed against DraftKings' selection data at pricing time; a line DraftKings has moved since you resolved it is a 422 line-not-echoed or line-mismatch (re-resolve and resend). DraftKings drops legs it will not combine (for example a moneyline together with the same team's spread) and prices the rest; the builder never serves that partial price: it is a 422 bet-failure whose failureCodes carry DraftKings' removal reasons in upper snake case (INCOMPATIBLE_SELECTIONS, REDUNDANT_SELECTION_REMOVED). | Note: DraftKings prices a different set of player props from FanDuel. The categories a player_prop leg can use on DraftKings, per sport: nfl: passing_yards, receiving_yards, and accepted: receptions, rushing_yards, longest_reception, rush_attempts, pass_attempts, pass_completions, interceptions, pass_rush_yards, passing_tds, longest_rush, rush_rec_yards. mlb: accepted: doubles, rbis, runs, hits, walks, hits-runs-rbis, singles, total-bases, stolen-bases, strikeouts, hits-allowed, earned-runs, walks-allowed. nba: points, rebounds, assists, threes_made, steals, blocks, pts_rebs_asts, pts_rebs, pts_asts, rebs_asts. nhl: goals, hockey_points, hockey_assists, shots_on_goal, saves, blocked_shots. ncaaf has no player prop categories. Football and basketball categories use the /props category names (passing_yards, receptions, points, threes_made, pts_rebs_asts; threes is accepted for threes_made), hockey categories use the /props NHL names (hockey_points, hockey_assists, shots_on_goal, saves, blocked_shots, goals), and baseball categories use the builder's MLB names (strikeouts, total-bases). DraftKings player props are priced as two-sided over/unders at the player's main line; the yes-only markets priced are NHL goals (the anytime goal scorer, taken as over 0.5) and NBA, NFL, NHL and MLB N-or-more rungs of a listed over/under category, before the game starts and, on NBA, NFL and NHL, in play (on DraftKings' live ladder): an over leg at N - 0.5 that is not the player's main line prices the N+ rung (over 24.5 is 25+ points, over 89.5 is 90+ receiving yards), with no under side. Other yes-only markets such as anytime touchdown, and NCAAF N-or-more rungs, are refused. Game legs per sport: nfl, ncaaf, mlb, nba and nhl: moneyline, spread and total. | Note: Hard Rock prices are Hard Rock Bet's own bet-slip price, quoted on its Arizona book (channel ARIZONA_ONLINE): eventId and the ids in bookSelection are the ones the Hard Rock Arizona feed (state az) serves, and a slip in another state can show a different price for the same legs. sgp.decimal is Hard Rock's own parlay price; sgp.american is that decimal expressed in American odds, rounded to a whole number the way the Hard Rock app displays it. Hard Rock game legs price at the main line, except on NHL, where an alternate puck line or total Hard Rock lists also prices, and a team_total leg prices each team's own total (full game, including overtime) at a line Hard Rock lists for that team. On NHL, Hard Rock also prices period legs: the 3-way result of the 1st, 2nd or 3rd period, and a period total or spread at a line it lists. Hard Rock confirms every line in its price response, so no Hard Rock leg is ever priced with lineUnverified. Hard Rock does not combine a moneyline with the same team's spread, or both sides of one total: it drops the leg rather than refusing the slip, and the builder never serves that partial price: the build is a 422 bet-failure with failureCodes INVALID_SELECTION. | Note: Hard Rock prices its own set of player props. The categories a player_prop leg can use on Hard Rock, per sport: nfl: receptions, passing_yards, and accepted: receiving_yards, rushing_yards, passing_tds, interceptions, pass_completions, pass_attempts, rush_rec_yards, touchdowns. mlb: accepted: strikeouts, hits, total-bases, rbis, home-runs, hits-runs-rbis. nhl: accepted: goals, hockey_assists, hockey_points, shots_on_goal, saves, goals_against, power_play_points, blocked_shots. ncaaf and nba have no player prop categories. Football categories use the /props category names (receptions, passing_yards), hockey categories use the /props NHL names (goals, shots_on_goal, hockey_points) plus blocked_shots, and baseball categories use the builder's MLB names (strikeouts, total-bases). A Hard Rock player_prop leg prices at a line Hard Rock lists for that player, including any alternate lines it publishes; a line it does not list is refused with the lines that exist, and a player name that matches more than one player is refused. touchdowns on Hard Rock is the anytime touchdown market, taken as over 0.5, and NHL goals at 0.5 is the anytime goal scorer market. Game legs per sport: nfl, ncaaf, mlb and nba: moneyline, spread and total; nhl: moneyline, spread, total and team_total; moneyline, spread and total also for one period (period P1, P2 or P3). | Note: BetRivers and Bally Bet prices are each book's own same game parlay price for exactly the legs sent, quoted from BetRivers Pennsylvania and Bally Bet New Jersey; a slip in another state can show a different price, and prices move, so compare only with a slip taken at the time in meta.pricedAt. Both list and price pregame games only. eventId is the book's numeric event id from /sgp/events?book=betrivers or ?book=ballybet, and a resolved leg carries bookSelection { marketId, selectionId }, the book's own offer and outcome ids. Game legs price at the main line, except on Bally Bet NHL, where an alternate puck line or total Bally Bet lists for same game parlays also prices (BetRivers prices main lines only). Each book confirms every leg's line and status in its price response, so a line it has moved since you resolved it is a 422 line-mismatch (re-resolve and resend). A combination the book will not price (for example both moneylines, or both sides of one total) is a 422 bet-failure with failureCodes COMBINATION_NOT_SUPPORTED; a leg it will not put in a same game parlay is a 422 leg-failure with failureCodes INVALID_OUTCOMES. | Note: BetRivers prices its own set of player props. The categories a player_prop leg can use on BetRivers, per sport: nfl: interceptions, and accepted: passing_yards, rushing_yards, receiving_yards, pass_attempts, passing_tds, pass_rush_yards, rush_rec_yards. nba: points, rebounds, assists, threes_made, pts_rebs_asts, pts_rebs, pts_asts, rebs_asts, steals, blocks, and accepted: steals_blocks. nhl: shots_on_goal, goals, hockey_points, hockey_assists. ncaaf and mlb have no player prop categories. Categories use the /props category names (passing_yards, interceptions, points, threes_made; hockey: shots_on_goal, goals, hockey_points, hockey_assists). A BetRivers player_prop leg prices at the line BetRivers posts for that player and side in the game; another line is refused naming the lines that exist, and a side BetRivers does not post is refused. On NBA and NHL, an over leg at N - 0.5 that is not the posted line prices the N+ rung (over 19.5 is 20+ points; NHL goals, hockey_points and hockey_assists: rungs only, over 0.5 is 1+), with no under side. On NFL, yes-only and N-or-more markets are not offered. Game legs per sport: nfl, ncaaf, mlb, nba and nhl: moneyline, spread and total. | Note: Bally Bet prices its own set of player props. The categories a player_prop leg can use on Bally Bet, per sport: nfl: interceptions, and accepted: passing_yards, rushing_yards, receiving_yards, pass_attempts, passing_tds, pass_rush_yards, rush_rec_yards. nba: points, assists, pts_rebs_asts, steals, blocks, steals_blocks, and accepted: rebounds, threes_made, pts_rebs, pts_asts, rebs_asts. nhl: shots_on_goal, goals, and accepted: hockey_points, hockey_assists. ncaaf and mlb have no player prop categories. Categories use the /props category names (passing_yards, interceptions, points, threes_made; hockey: shots_on_goal, goals, hockey_points, hockey_assists). A Bally Bet player_prop leg prices at the line Bally Bet posts for that player and side in the game; another line is refused naming the lines that exist, and a side Bally Bet does not post is refused. On NBA and NHL, an over leg at N - 0.5 that is not the posted line prices the N+ rung (over 19.5 is 20+ points; NHL goals, hockey_points and hockey_assists: rungs only, over 0.5 is 1+), with no under side. On NFL, yes-only and N-or-more markets are not offered. Game legs per sport: nfl, ncaaf, mlb, nba and nhl: moneyline, spread and total. | Note: BetMGM prices are BetMGM's own same game parlay price for exactly the legs sent, quoted from BetMGM New Jersey; a slip in another state can show a different price, and prices move, so compare only with a slip taken at the time in meta.pricedAt. BetMGM prices NFL, NBA and NHL, pregame games only. eventId is BetMGM's fixture id from /sgp/events?book=betmgm. Game legs price at the main line only (the spread and total BetMGM lists closest to balanced, the rule /odds uses; when two rungs are equally close, a leg at either rung's line prices, where /odds shows the first listed): an alternate spread or total is refused with the main line. On NFL and NBA, BetMGM's price response repeats every leg's line, status and single price, so a line it has moved since you resolved it is a 422 line-mismatch (re-resolve and resend). NHL is priced through BetMGM's other bet builder: each leg is sent as the option that builder lists for the game, and its price response is one price that names no leg and carries no single, so on NHL legs[].single is null and every leg is in meta.lineUnverifiedLegs; NHL player props are N-or-more rungs (side over at N - 0.5), with no under side. On NHL, a combination the builder will not price (Over with Under of one total) is a 422 bet-failure with failureCodes ZERO_PROBABILITY, or REDUNDANT_LEG when it names the leg it would drop. On NFL and NBA, both sides of one total are a 422 bet-failure with failureCodes ZERO_PROBABILITY; a moneyline with the same team's spread is a 422 bet-failure with failureCodes REDUNDANT_LEG (BetMGM would price the other leg alone, and the builder never serves that partial price); a leg BetMGM's price engine reports no trading data for is a 422 leg-failure with failureCodes NO_TRADING_DATA. | Note: BetMGM prices its own set of player props. The categories a player_prop leg can use on BetMGM, per sport: nfl: receptions, and accepted: passing_yards, rushing_yards, receiving_yards, passing_tds, pass_completions, rush_attempts, longest_reception, pass_rush_yards, rush_rec_yards. nba: points, and accepted: rebounds, assists, threes_made. nhl: accepted: hockey_points, hockey_assists, shots_on_goal, goals. Categories use the /props category names (passing_yards, interceptions, points, threes_made; hockey: shots_on_goal, goals, hockey_points, hockey_assists). A BetMGM player_prop leg prices at the line BetMGM posts for that player and side in the game; another line is refused naming the lines that exist, and a side BetMGM does not post is refused. Yes-only and N-or-more markets are not offered as player_prop legs. Game legs per sport: nfl, nba and nhl: moneyline, spread and total. | Note: Caesars prices are Caesars' own same game parlay price for exactly the legs sent, quoted from Caesars Colorado (other states can differ; compare with a slip taken at meta.pricedAt). NFL, NCAAF, NBA and NHL, pregame only; eventId is Caesars' event id (a UUID) from /sgp/events?book=caesars. Game legs price at the line /odds shows for Caesars; another point is refused naming it. Caesars' answer repeats every leg's line: a moved line is a 422 line-mismatch. A leg Caesars will not combine is a 422 leg-failure with failureCodes LEG_EXCLUDED, never a partial price. | Note: Caesars prices its own set of player props. The categories a player_prop leg can use on Caesars, per sport: nba: points, and accepted: rebounds, assists, threes_made, pts_rebs_asts, pts_rebs, pts_asts, rebs_asts. nhl: shots_on_goal, and accepted: hockey_points, hockey_assists. nfl: receptions, and accepted: passing_yards, rushing_yards, receiving_yards, passing_tds, pass_completions, pass_attempts, rush_attempts, interceptions, pass_rush_yards, longest_reception, longest_rush, longest_completion, field_goals_made, extra_points_made, kicking_points. ncaaf: accepted: passing_yards, rushing_yards, receiving_yards, receptions, passing_tds. Categories use the /props category names (passing_yards, interceptions, points, threes_made; hockey: shots_on_goal, goals, hockey_points, hockey_assists). A Caesars player_prop leg prices at the line Caesars posts for that player and side in the game; another line is refused naming the lines that exist, and a side Caesars does not post is refused. Yes-only and N-or-more markets are not offered as player_prop legs. Game legs per sport: nba, nhl, nfl and ncaaf: moneyline, spread and total. | Note: On FanDuel, NBA and WNBA player_prop legs price the player's full-game over/under line. Quarter and alternate lines are not used, so a line that exists only as a quarter or alternate line is refused with the full-game lines that do exist. | Note: A refused player_prop leg names the category you sent and, for a betting-name alias, the props category it stands for. The aliases: anytime_touchdown, anytime-touchdown, anytime_td, anytime-td, atd and touchdown_scorer for touchdowns; first_td and first-td for first_touchdown_scorer. | Note: Batter props on FanDuel are yes/no N-or-more markets, exposed as the over side of N-0.5 (over 0.5 = 1+). They have no under side. | Note: NFL touchdowns and first_touchdown_scorer are yes/no touchdown-scorer markets on FanDuel, exposed the same way: touchdowns over 0.5 is the anytime touchdown scorer, over 1.5 is 2 or more, over 2.5 is 3 or more; first_touchdown_scorer takes over 0.5. They have no under side. NFL passing_yards, rushing_yards, receiving_yards, receptions and passing_tds legs price FanDuel's main line for that player; alternate lines are not used, so a line that exists only on an alternate ladder is refused with the main line that does exist. | Note: Anytime touchdown is priced on FanDuel as touchdowns over 0.5. Hard Rock accepts touchdowns over 0.5 as its anytime touchdown market, not yet verified on a priced parlay. DraftKings does not price it yet: a touchdowns or first_touchdown_scorer leg sent to DraftKings is refused with a 422 before pricing, like every yes-only market except NHL goals (the anytime goal scorer) and, before the game starts, the NBA, NFL, NHL and MLB N-or-more rungs of a listed over/under category. | Note: Builds can be limited per API key per minute, separately from your plan rate limit. Over that limit the call returns 429 { error, code: "SGP_RATE_LIMIT", limit, resetAt } with a Retry-After header, and the refused build does not count against your plan quota. A malformed body, an unsupported or disabled book, and a request refused by your plan rate limit or concurrent request limit do not count toward the per-minute build limit. Every other build does, including one refused afterwards: an invalid eventId (400), a game not found (404), an unresolvable leg (422), a refused parlay (422 with a reasonCode), no price (503 with reasonCode no-price) or SGP_BUSY (503). | Note: A book outside supportedBooks is a 400 with code SGP_BOOK_UNSUPPORTED and supportedBooks, the books the builder prices. A supported book that does not price the requested sport is a 400 with code SGP_BOOK_SPORT_UNSUPPORTED and sports, the sports it does price. | Note: 503 means retry later: code SGP_BUSY when pricing capacity is reached (Retry-After: 2), SGP_BOOK_DISABLED when pricing for the requested book is paused (Retry-After: 60; a book whose pricing misbehaves may be paused this way, and a new book may be enabled for selected keys before general availability), and SGP_RATE_LIMIT_UNAVAILABLE when the build limit could not be checked. A 503 with reasonCode no-price (code no-price, Retry-After: 2) means the book produced no usable answer, to the price request or to the read of the game's markets before it: retry later. A 503 without a code is a temporary outage on our side (for example pricing, the rate limit system or subscription verification): back off and retry. The rate limit case carries limit: "system". | Note: A build that reached pricing and returned no price is a 422 or a 503 with the same body; the status says whether to resend. 422 means the book judged the slip and refused it, or its answer did not confirm your legs as sent: the request was valid but cannot be priced as specified, so do not resend the same body unchanged. Its reasonCode is one of duplicate-selection, resp-code, leg-failure, bet-failure, bet-failure-shape, no-sgm-combination, ambiguous-combination, combined-odds-missing, single-missing, single-duplicate, single-odds-missing, runner-odds-missing, runner-odds-duplicate, runner-odds-inconsistent, not-placeable, line-not-echoed, line-mismatch, line-unexpected, line-unverifiable. 503 with reasonCode no-price (and Retry-After: 2) means no usable answer came back at all: the price request failed, timed out or was answered with something other than a price. Retry later. code repeats reasonCode on both (the SDKs expose it as the error's code), so branch on reasonCode first: no-price to retry, unresolved to fix the legs, anything else to change the slip. 502 is not used by this endpoint. | Note: On a refused 422, reasonCode is line-mismatch when the book priced a leg at a different line than requested (re-resolve and resend), bet-failure or leg-failure when the book rejected the parlay or dropped a leg from it (the book's own codes are in failureCodes: FanDuel for example SELECTION_NOT_FOUND, DraftKings INCOMPATIBLE_SELECTIONS or REDUNDANT_SELECTION_REMOVED, Hard Rock INVALID_SELECTION, BetRivers and Bally Bet COMBINATION_NOT_SUPPORTED or INVALID_OUTCOMES, BetMGM ZERO_PROBABILITY, REDUNDANT_LEG or NO_TRADING_DATA), not-placeable when the book is not accepting bets on these legs (for example a suspended market), or another acceptance code when the book's response did not confirm exactly these legs. For line-mismatch and the other line-* codes, legIndex is the leg in your request and lineEchoed is the line the book priced, exactly as it sent it (null when it sent none). message is human-readable prose; match on reasonCode, legIndex, lineEchoed and failureCodes, not on message. | SDK: ts buildSgp() / py build_sgp() #### POST /api/v1/{sport}/sgp/compare | Prices one same game parlay on every same game parlay book at once (FanDuel, DraftKings, Hard Rock, BetRivers, Bally Bet, BetMGM and Caesars), from our event id, and returns each book's own answer side by side with the best price. Each book's answer is exactly what /sgp/build returns for it. | Access: MVP and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Body: A JSON object with eventId (our event id for the game, as /api/v1/{sport}/odds gives it: {sport}:{Away}@{Home}-{YYYYMMDD}; for the second game of a doubleheader, with -G2), legs (the same legs /sgp/build takes, 1 to 12), and optionally books (an array of book names to ask; default every book), deadlineMs (how long to wait for the books, 1000 to 15000; default 6000), fanduel ('cold', the default, or 'skip': FanDuel prices in a fresh browser session and is given at least 12000 ms), fair (boolean: add our fair-price estimate, as on /sgp/build) and fresh (boolean: never use a cached quote). | Response: { success, sport, eventId, legs, best: { book, american, decimal } | null, books: [{ book, label, status, bookEventId?, httpStatus?, result?: , cached?, reason?, ms? }], fair?: | null, meta: { timestamp, deadlineMs, legsHash, booksAsked, booksCached, booksQuoted, booksPriced } } | Note: status says what happened on each book: priced (result is the 200 body), refused (the book refused the parlay: result is its 422 body), unresolved (a leg did not resolve on that book, for example a different main line: result says which and why), busy or no-price (that book produced no usable answer: retry later), timeout (the book had not answered when deadlineMs ran out, or was not reached in time), event-not-listed (the book does not list this game for same game parlays when asked), ambiguous-event (two games match, a doubleheader without -G2, or the book lists this matchup at a different start than this game, or than the other books when the game is under way, so it is a different game), unsupported (the book does not price this sport or one of the legs: reason says which), disabled (that book's pricing is paused) or skipped (fanduel: skip). | Note: The same legs are sent to every book. Books price game legs at their own lines: a spread or total at a number one book does not list (its main line, or on NHL one of the alternates Hard Rock, FanDuel, Bally Bet and, before the game, DraftKings list) is unresolved on that book, with the book's lines in the reason (FanDuel's names only the point, except on NHL while alternate lines are on); a team_total leg is unsupported on every book except Hard Rock and FanDuel on NHL, and a leg with a period on every book except Hard Rock and, for a period total, FanDuel on NHL. best is the highest decimal price among the books that priced. BetRivers and Bally Bet are priced one after the other within the same deadline. | Note: Before a game starts, a book that priced exactly these legs for this game in the last 15 seconds (through /sgp/build or /sgp/compare, for any customer) may be answered from that quote: cached is true, result.meta.cached is true and result.meta.cacheAgeMs is the quote's age when the response was sent; result.meta.pricedAt is still when the book priced it. A cached answer costs no build. Send fresh: true to price every book now. A game that starts within 30 seconds, or is already under way, is always priced fresh. | Note: Every book asked that is not answered from a cached quote counts one build against your per-minute SGP build limit, reserved for the whole comparison before any book is priced; a book that is never asked for a price (not listed, a different game, not reached in time, or a leg that does not resolve) is given back at once while the minute it was counted in is still running. Over the limit the call is a 429 SGP_RATE_LIMIT with unitsNeeded and a Retry-After header. When the comparison needs more builds than your limit allows at all, it is a 429 SGP_COMPARE_TOO_MANY_BOOKS with maxBooks and no Retry-After: ask for at most maxBooks books with books. A comparison counts as one request against your plan rate limit. | Note: MVP and Hall of Fame plans. On a lower plan the call is a 403 with code SGP_TIER_REQUIRED; a 503 with code SGP_FEATURE_DISABLED means comparisons are not switched on yet. | SDK: ts compareSgp() / py compare_sgp() #### GET /api/v1/{sport}/sgp/events | Lists a book's games for a sport together with the eventId the SGP builder needs (FanDuel unless ?book= names another). | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Query: book (optional) - The book whose games to list, one of fanduel, hardrock, draftkings, betrivers, ballybet, betmgm, caesars (the books the builder prices, listed in supportedBooks on a 400 SGP_BOOK_UNSUPPORTED). Default fanduel. A book whose pricing is paused is a 503 SGP_BOOK_DISABLED, as on the build call. | Response: { success, sport, book, count, events: [{ eventId, matchup, away, home, start, competition }], meta: { timestamp } } | SDK: ts getSgpEvents() / py get_sgp_events() #### GET /api/v1/{sport}/sgp/history | Returns the same game parlay prices your account was quoted for one game, newest first: the legs, the book's price and each leg's own price, from /sgp/build, /sgp/compare and /sgp/batch. | Access: MVP and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball | Query: eventId (optional) - Our event id for the game ({sport}:{Away}@{Home}-{YYYYMMDD}, as /api/v1/{sport}/odds gives it). Give this, or book and bookEventId. | Query: book (optional) - With bookEventId: one book's quotes for the game, by that book's own event id. | Query: bookEventId (optional) - The book's own event id, from /sgp/events (meta.eventId on a quote). | Query: legsHash (optional) - Only quotes for this parlay: the 16 hex characters of meta.legsHash on a priced /sgp/build answer. | Query: from (optional) - ISO 8601 start of the window (inclusive). Default: 24 hours before to. | Query: to (optional) - ISO 8601 end of the window (exclusive). Default: now. The window is at most 7 days. | Query: limit (optional) - At most this many quotes, 1 to 500. Default 100. | Response: { success, sport, quotes: [newest first: { quotedAt, book, bookEventId, eventId, legsHash, legs, sgp: { american, decimal }, singles: [each leg's own price or null], fair: { decimal, correlationFactor } | null, via: build | compare | batch }], meta: { count, limit, from, to, eventId | book and bookEventId, legsHash? } } | Note: Only your own quotes (no other account can read them), priced answers only (not a refusal, a no-price, a busy answer or a quote served from the quote cache), kept 90 days, from the day recording was switched on: nothing earlier is filled in. | Note: quotedAt is meta.pricedAt; fair is set when the quote carried one (/sgp/build with fair: true). Every priced /sgp/build, /sgp/compare and /sgp/batch result carries meta.legsHash: the same parlay sent the same way has the same hash, so legsHash narrows the history to one parlay. | Note: MVP and Hall of Fame plans: on a lower plan a 403 SGP_TIER_REQUIRED. A 503 SGP_FEATURE_DISABLED means it is not switched on yet, a 503 SGP_HISTORY_UNAVAILABLE (Retry-After: 5) a failed read. A parameter out of range, or a game named both ways, is a 400. | SDK: ts getSgpHistory() / py get_sgp_history() #### POST /api/v1/sgp/combo-compare | Prices a Kalshi combo as the same game parlay it is: each Kalshi leg is translated to the leg a sportsbook prices, the game is matched to our event id, and the legs are priced on every same game parlay book exactly as /sgp/compare does. It never returns a Kalshi price for the combo itself. | Access: MVP and above | Body: A JSON object with venue ('kalshi') and either comboTicker (a Kalshi combo market ticker; its legs are read from Kalshi) or legs (2 to 12 of { marketTicker, side: 'yes' | 'no' }), plus the optional books, deadlineMs, fanduel, fair and fresh of /sgp/compare. Tickers may be sent in lower case. | Response: { success, venue, combo: { ticker, legs: [{ marketTicker, side, title, translated: | null, why }] }, venuePrice: { yesBid, yesAsk, status } | null, eventId, compare: } | Note: A game winner YES is that team's moneyline, a spread YES the team at minus the line (NO: the other team at plus it), a total or team total YES the over, and a player "N+" market YES the over N - 0.5 (NO the under); a line market must be greater-than at a half point. A NO on a game winner, other periods, soccer and non-sports markets are not translated. | Note: Refusals: a 400 COMBO_INVALID for a malformed body; a 404 COMBO_NOT_FOUND for an unknown comboTicker and a 422 COMBO_NOT_A_COMBO for a market that does not list 2 to 12 legs; a 422 COMBO_LEGS_UNSUPPORTED when a leg has no equivalent (combo.legs[].why says why), COMBO_MULTI_GAME when the legs span games (games[] groups them), COMBO_EVENT_NOT_FOUND when the game is not on our pregame board or cannot be told from another game of the pair that day; a 503 COMBO_VENUE_UNAVAILABLE (Retry-After) when Kalshi could not be read. | Note: venuePrice is the combo market's own quote on Kalshi in dollars, for context (null without a resting quote). Builds count against your SGP build limit as on /sgp/compare. MVP and Hall of Fame plans (403 SGP_TIER_REQUIRED below); a 503 SGP_FEATURE_DISABLED means combos, comparisons or (with fair) the fair price are not switched on yet. | SDK: ts compareSgpCombo() / py compare_sgp_combo() The builder prices a Same Game Parlay at the sportsbook named in the body field book: fanduel (the default), draftkings, hardrock, betrivers, ballybet, betmgm or caesars. The Prop Builder ([sub-propbuilder-v2]) is not one of them: it is a board of single-leg prices, and book: propbuilder is a 400 SGP_BOOK_UNSUPPORTED like any other unsupported book. Every price is that book's own same game parlay pricing for exactly the legs sent, verified leg by leg; the builder never computes a price and never substitutes another book's, so isSGM is always true on a 200, and a book whose pricing misbehaves may be paused, in which case its builds are 503 SGP_BOOK_DISABLED (Retry-After: 60) until it is back. Each book quotes from one region, so a slip in another state can show a different price for the same legs, and prices move, so compare only against a slip taken at meta.pricedAt. FanDuel: New Jersey quotes; FanDuel applies a correlation model, so correlated legs (a team moneyline plus one of its players going over) price differently from the product of the singles; fdSelection.marketId on each leg is New Jersey's id, FanDuel issues a different marketId in every other state and in Ontario while selectionId is the same everywhere, so outside New Jersey place with your region's marketId for that selectionId. DraftKings: DraftKings' own same game parlay pricing, New York quotes; the betslip may differ during a price move. Hard Rock: Hard Rock Bet's own bet slip pricing, Arizona quotes; sgp.decimal is Hard Rock's parlay price and sgp.american is that decimal in American odds, rounded to a whole number the way the Hard Rock app displays it. BetRivers and Bally Bet: each book's own same game parlay pricing, Pennsylvania (BetRivers) and New Jersey (Bally Bet) quotes. The player_prop legs a book prices in a parlay are not a list of where its player props are served: BetRivers' and Bally Bet's player props come through their Source API routes (/api/v2/betrivers/{sport} and /api/v2/ballybet/{sport}), not /api/v1/{sport}/props. Caesars: Colorado quotes, pregame only. BetMGM: BetMGM's own same game parlay pricing, New Jersey quotes. BetMGM NHL goes through BetMGM's other bet builder: each leg is sent as the option that builder lists for the game, and its answer is one price that names no leg, so an NHL 200 lists every leg in meta.lineUnverifiedLegs and legs[].single is null; NHL player props there are N-or-more rungs (side over at N - 0.5, no under side). A resolved leg carries the book's own ids: fdSelection { marketId, selectionId } on FanDuel, bookSelection { marketId, selectionId } on every other book (Hard Rock's, BetRivers' and Bally Bet's are strings of digits; BetMGM's marketId reads v1:: or v2::, or sc:: on NHL with the builder's option string as selectionId); only one of the two is present on a leg. eventId is the book's own id from /sgp/events (add ?book= for a book other than FanDuel), sent as a string, and is short-lived; an Owls Insight slug like "wnba:CHI@LAS-20260716" returns 400 "Invalid eventId". Required leg fields, and a wrong field name is a 400: moneyline { selection }; spread and total { selection, point } ("point", NOT "line"); team_total { team, selection: "over" | "under", point }; player_prop { player, category, side: "over" | "under", line }. On NHL a moneyline, spread or total leg may add period: "P1" | "P2" | "P3" (Hard Rock; FanDuel takes period totals only). In the response sgp.legs is a COUNT, not the leg array; each top-level legs entry carries its stand-alone price under single (null when none) and there is no american field directly on a leg. Sports per book: fanduel prices wnba, nba, ncaab, nfl, ncaaf, nhl, mlb, soccer and tennis; draftkings prices nfl, ncaaf, mlb, nba and nhl; hardrock prices nfl, ncaaf, mlb, nba and nhl; betrivers prices nfl, ncaaf, mlb, nba and nhl; ballybet prices nfl, ncaaf, mlb, nba and nhl; betmgm prices nfl, nba and nhl; caesars prices nfl, ncaaf, nba and nhl. A supported book sent a sport it does not price is a 400 SGP_BOOK_SPORT_UNSUPPORTED listing sports. Leg support is per book: the player_prop categories a leg can use, verified or accepted but not verified, are in the build block's coverage Note (one table per book on the docs page) and are NOT the /props categories. Game legs are moneyline, spread and total on every book and sport except FanDuel soccer and tennis, which take moneyline legs only (a spread or total leg there is a 422) and no player_prop leg, so the builder cannot price a soccer or tennis parlay. team_total (one team's own full-game total, at a line the book lists for that team) prices on Hard Rock and FanDuel NHL only; every other book and sport refuses it as a leg type it does not price. A period leg (one period instead of the full game; a period moneyline is the 3-way period result, a team or Tie) prices on Hard Rock NHL, at a line Hard Rock lists for that period, and as a period total on FanDuel NHL, at its 1st period main total or an alternate total it lists for that period; every other book, and FanDuel for a period moneyline or spread, refuses it with a 422 and never prices the full game in its place, and there is no regulation-time leg. Spread and total legs price at the book's main line (on BetMGM, when two rungs are equally close to balanced, a leg at either rung's line prices), except that on NHL Hard Rock, FanDuel, Bally Bet and, before the game, DraftKings also price an alternate puck line or total they list: send the point you want; a point the book does not list is refused naming its lines. While alternates and team_total are switched off, an alternate point is refused naming the main line (FanDuel's names only the point) and team_total is a 400 unknown leg type. BetRivers, Bally Bet and BetMGM list and price pregame games only. Hard Rock refuses a moneyline combined with the same team's spread. BetMGM refuses a moneyline with the same team's spread (a 422 bet-failure with failureCodes REDUNDANT_LEG) and both sides of one total (ZERO_PROBABILITY); BetRivers and Bally Bet refuse a combination they will not price (COMBINATION_NOT_SUPPORTED) and a leg they will not put in a same game parlay (a 422 leg-failure, INVALID_OUTCOMES). DraftKings prices two-sided over/under player props plus two yes-only kinds: NHL goals, the anytime goal scorer market (side over, line 0.5 only), and NBA, NFL, NHL and MLB N-or-more rungs of a listed over/under category, before the game starts and, on NBA, NFL and NHL, in play on DraftKings' live ladder (side over at N - 0.5, so over 24.5 is 25+ points and over 89.5 is 90+ receiving yards; a leg at the player's main line still prices the over/under, and a rung has no under side; in play a rung DraftKings has already moved off its live ladder is refused). Other yes-only markets such as anytime touchdown, and NCAAF N-or-more rungs, are refused, and DraftKings drops legs it will not combine (a moneyline with the same team's spread, both sides of one total): the builder never serves that partial price and returns a 422 bet-failure whose failureCodes carry DraftKings' removal reasons (INCOMPATIBLE_SELECTIONS, REDUNDANT_SELECTION_REMOVED). Anytime touchdown (touchdowns over 0.5) is priced on FanDuel only: DraftKings does not price it and on Hard Rock it is accepted but not verified. On FanDuel every NBA player_prop category is accepted but not verified, so an NBA prop leg may be refused. A sport with no player_prop categories on the chosen book (FanDuel NCAAB, NCAAF, soccer and tennis; DraftKings NCAAF; Hard Rock NBA and NCAAF; BetRivers MLB; Bally Bet NCAAF, MLB, NBA and NHL) refuses every prop leg with a 422 before pricing whose reason names the category sent and, for an alias such as anytime_touchdown, the /props category it stands for (touchdowns). meta.pricedAt is the quote time (when the book's price came back); meta.timestamp and meta.resolvedAt are the earlier instant the legs were resolved, never the price time. meta.lineUnverified with meta.lineUnverifiedLegs flags legs priced without the book confirming the line (FanDuel's alternate strikeout rungs, and every BetMGM NHL leg); DraftKings, Hard Rock, BetRivers, Bally Bet, and BetMGM on NFL and NBA confirm every line, so a leg there is never priced with lineUnverified. Compare (MVP and Hall of Fame): POST /api/v1/{sport}/sgp/compare prices one parlay on every same game parlay book at once; each books[] result is that book's /sgp/build body. Unlike /sgp/build, its eventId is OUR id exactly as /api/v1/{sport}/odds gives it (-G2 for a doubleheader's second game), never a book's id, and the same legs go to every book, so a point one book does not list is unresolved there. A cached answer (books[].cached and result.meta.cached true, result.meta.cacheAgeMs its age when the response was sent) costs no build, and fresh: true prices every book now. Every other book asked counts one build: over the limit is 429 SGP_RATE_LIMIT (unitsNeeded, Retry-After), and more books than the limit allows at all is 429 SGP_COMPARE_TOO_MANY_BOOKS (maxBooks, no Retry-After: ask for at most maxBooks books). A lower plan gets 403 SGP_TIER_REQUIRED; 503 SGP_FEATURE_DISABLED means comparisons are not switched on yet. Batch (MVP and Hall of Fame): POST /api/v1/{sport}/sgp/batch prices several parlays one after the other: mode "book" (up to 10, all on book) or "compare" (up to 4, each on every book as /sgp/compare). Each parlay is { ref?, eventId, legs } with OUR eventId. A result's status is done, or not-started for a parlay whose pricing would begin more than about 20 seconds after the call began (send it again); bookStatus and books[].status take the /sgp/compare statuses, and fair is a block on each parlay that was priced (null when no book priced it, none when not-started). Each uncached quote counts one build (one per parlay in mode book, one per book and parlay in mode compare): over the limit is 429 SGP_RATE_LIMIT, and a batch that needs more builds than the limit allows at all is 429 SGP_BATCH_TOO_LARGE (unitsNeeded, no Retry-After). Mode book has /sgp/build's book errors (400 SGP_BOOK_UNSUPPORTED, 400 SGP_BOOK_SPORT_UNSUPPORTED, 503 SGP_BOOK_DISABLED); a lower plan gets 403 SGP_TIER_REQUIRED, and 503 SGP_FEATURE_DISABLED means batches are not switched on yet. Fair price (fair: true on /sgp/build, /sgp/compare or /sgp/batch, MVP and Hall of Fame): adds a fair block, our labelled estimate from de-vigged leg probabilities, never the book's price (sgp is unchanged and fair.estimate is always true); the /sgp/build notes describe its legs, basis and when fair.price is null. On /sgp/compare and batch mode compare, basis.correlationFactor is the median of the priced books' factors, each listed in basis.correlationSources, and /sgp/compare carries one fair block, from every book that priced, or null. fair comes on priced 200s only. On Rookie, fair: true is 403 SGP_TIER_REQUIRED, sent before anything is priced and not counted against any limit; 503 SGP_FEATURE_DISABLED means the fair price is not switched on yet. Errors: 404 "Game not found" is a stale id (re-list); 400 code SGP_BOOK_UNSUPPORTED lists supportedBooks, and SGP_BOOK_SPORT_UNSUPPORTED lists the sports that book prices. 429 is the plan rate or concurrent request limit, or code SGP_RATE_LIMIT { limit, resetAt }, the per-key build limit, which costs no plan quota. 422 is a valid request that cannot be priced as sent. Every 422 body carries reasonCode (also sent as code). reasonCode "unresolved": a leg did not resolve and legs carries a reason per leg, so fix the legs; nothing was priced. Any other reasonCode is a refused parlay: the book judged the slip and refused it, or its answer did not confirm your legs as sent, so do not resend the same body unchanged. On a refused 422, bet-failure and leg-failure carry the book's own codes in failureCodes, for example FanDuel's codes, DraftKings' removal reasons in upper snake case, Hard Rock INVALID_SELECTION, BetRivers and Bally Bet COMBINATION_NOT_SUPPORTED or INVALID_OUTCOMES, and BetMGM ZERO_PROBABILITY, REDUNDANT_LEG or NO_TRADING_DATA; not-placeable means the book is not taking bets on these legs; line-mismatch and the other line-* codes set legIndex (the leg in your request) and lineEchoed (the line the book priced, null when it sent none), so re-resolve and resend. Match on reasonCode, legIndex, lineEchoed and failureCodes, never on message. 503 is retry later: reasonCode no-price (code no-price, Retry-After: 2) means no usable answer came back, to the price request or to the read of the game's markets before it; SGP_BUSY and SGP_RATE_LIMIT_UNAVAILABLE send Retry-After: 2; SGP_BOOK_DISABLED (Retry-After: 60) means pricing for that book is switched off, a paused book included, and the builder does not price it at another book; no code is a temporary outage on our side, such as pricing, the rate-limit system (carries limit: "system") or subscription verification, so back off and retry. 502 is not used by the builder. When Retry-After is present wait that many seconds before resending the same body; never retry in a tight loop. The SDKs (TypeScript 0.57.0+, Python 0.21.0+) return the 422 body instead of throwing, so check success before reading sgp; the 503s throw ServiceBusyError (code no-price when no price came back) carrying the retry delay (retryAfterMs / retry_after_ms). The SDK book parameter takes any of the six books, and the SDKs' events call takes the same book from TypeScript 0.64.0 and Python 0.22.0 (getSgpEvents(sport, { book }), get_sgp_events(sport, book); before those it lists FanDuel's events, so call GET /api/v1/{sport}/sgp/events?book= directly), so list a book's events and pass the eventId it returns to that same book. ```typescript const events = await client.rest.getSgpEvents("wnba"); const result = await client.rest.buildSgp("wnba", { eventId: String(events.events[0].eventId), book: "fanduel", legs: [ { type: "moneyline", selection: "Chicago Sky" }, { type: "player_prop", player: "Nneka Ogwumike", category: "points", side: "over", line: 16.5 } ] }); // A 422 is returned, not thrown. A 503 throws ServiceBusyError (code "no-price": wait retryAfterMs, then retry). if (result.success && result.sgp) console.log(result.sgp.american, result.sgp.decimal); else if (result.legs.some((l) => !l.resolved)) console.log(result.legs.filter((l) => !l.resolved).map((l) => l.reason)); // reasonCode "unresolved": fix the legs else console.log(result.error); // a refused parlay: the body's reasonCode says why; do not resend it unchanged // DraftKings: the same call with book "draftkings" and a DraftKings eventId (from /sgp/events?book=draftkings). const dk = await client.rest.buildSgp("nfl", { eventId: "34118219", book: "draftkings", legs: [ { type: "moneyline", selection: "Buffalo Bills" }, { type: "total", selection: "over", point: 54.5 } ] }); if (dk.success && dk.sgp) console.log(dk.sgp.american, dk.legs[0].bookSelection); // DraftKings' own { marketId, selectionId } ``` ## [splits-api] Betting splits (docs: https://owlsinsight.com/docs#splits-api) #### GET /api/v1/{sport}/splits | Returns betting splits for a sport: handle (money) percent and ticket (bet) percent on each side of the spread, the total and the moneyline of each game, per book, from DraftKings, BetMGM (tickets only) and Circa Sports. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, wnba | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, soccer, table_tennis, tennis, valorant, volleyball | Response: { sport, data: [games: event_id, home_team, away_team, splits: [per book: book, as_of, per-market handle and ticket percentages]], meta: { source, books, total_games, status, partial_reason?, as_of?, age_seconds? } } | Note: event_id is the eventId /api/v1/{sport}/odds gives the same game ({sport}:{Away}@{Home}-{YYYYMMDD}), and home_team and away_team are the /odds team names, so splits join to odds on event_id. A game not on /odds gets an id built the same way from its scheduled start. Both games of a doubleheader share one event_id, as on /odds. | Note: Each entry in splits carries as_of, when that book's figures were read. The books are read on different schedules, so two books on one game can show different times. | Note: DraftKings figures run until kickoff. After kickoff the last pre-game DraftKings figures stay on the board for up to 4 hours, with the as_of of that last read. | Note: A market a book has not published for a game is absent from that game rather than reported as zero. | Note: meta.books lists the books on the board. meta.as_of is the oldest of their latest reads; a split kept after kickoff carries its own, older as_of. meta.source is a fixed value; read meta.books for the books. | Note: meta.status is "ok" when every book has a recent read, "partial" when one does not (data may then be a subset) and "stale" when no book has one and the last full board, at most 12 hours old, is served instead; meta.age_seconds is then present. meta.partial_reason says which: "book_unavailable" (that book's figures could not be read; it is absent from meta.books), "upstream_limited" (the Circa figures were withheld) or "upstream_paused" (no book could be read and no board from the last 12 hours is available). | Note: BetMGM publishes ticket percentages only, so its entries (book betmgm) carry the bets fields and no handle fields; a percentage absent from any book is not zero. | Note: DraftKings lists splits for only about 50 games of a sport per day, so on a busy slate, such as a full college football Saturday, many games (mostly smaller-conference ones) have no DraftKings split. | SDK: ts getSplits() / py get_splits() Share of tickets and share of handle per side of each market, per book, keyed on the same event_id as /odds; reverse line movement (the line moving against the ticket-heavy side) is the classic use. Books: DraftKings, Circa Sports while it is offered, and BetMGM. BetMGM publishes ticket percentages only, so its entries (book betmgm) carry the bets fields and no handle fields; a percentage absent from any book is not zero. meta.books lists the books on each board; read it rather than assuming all three. The recorded movement of these figures is GET /api/v1/history/splits (MVP and above), in the same entry shape with recorded_at in place of as_of. ## [normalize-api] Team normalization (docs: https://owlsinsight.com/docs#normalize-api) #### GET /api/v1/normalize | Returns the canonical team name for a raw name, so events can be matched across sportsbooks and score feeds. | Access: All paying tiers | Query: name (required) - Team name as a sportsbook or feed spells it. Up to 200 characters. | Query: sport (required) - Sport the name belongs to. [values: nba, wnba, ncaab, nfl, nhl, ncaaf, mlb, soccer, tennis, cs2, dota2, lol, valorant] | Response: { input, canonical, simplified?, sport } | Note: simplified is present only for sports that also carry a shortened matching form. | Note: Not needed for names this API returns: home_team and away_team on the odds and events endpoints are already the one canonical string per team for NFL, college football, NBA, NHL, MLB and college basketball. Use it to map an outside name onto ours. | Note: Soccer: a name carrying Women, an age (U21, U-23, Under 20), Youth, Academy or Reserves is never mapped onto the senior club and comes back as sent: Manchester City Women returns Manchester City Women, not Manchester City. Other spellings of a women's or reserve side, such as a trailing W, Ladies, Femenino, Frauen or a reserve team's own name, are not recognised and can come back as the senior club. | SDK: ts normalize() / py normalize() #### GET /api/v1/normalize/batch | Returns canonical team names for up to 25 raw names in one call. | Access: All paying tiers | Query: names (required) - Comma-separated team names, up to 25, each up to 200 characters. | Query: sport (required) - Sport the names belong to. [values: same list as sport on GET /api/v1/normalize] | Response: { sport, results: [{ input, canonical, simplified? }] } | Note: Not needed for names this API returns: home_team and away_team on the odds and events endpoints are already the one canonical string per team for NFL, college football, NBA, NHL, MLB and college basketball. Use it to map an outside name onto ours. | Note: Soccer: a name carrying Women, an age (U21, U-23, Under 20), Youth, Academy or Reserves is never mapped onto the senior club and comes back as sent: Manchester City Women returns Manchester City Women, not Manchester City. Other spellings of a women's or reserve side, such as a trailing W, Ladies, Femenino, Frauen or a reserve team's own name, are not recognised and can come back as the senior club. | SDK: ts normalizeBatch() / py normalize_batch() Returns the canonical team name the odds endpoints use for a name spelled another way ("Man Utd"), so external names match home_team / away_team. The batch route returns one entry per input in order with a matched flag; unknown names are matched false, not errors. ## [scores-api] Live scores (docs: https://owlsinsight.com/docs#scores-api) #### GET /api/v1/{sport}/plays | Returns the play-by-play of one NBA, NHL, NFL, NCAAF or soccer game in play (or ended within the last 6 hours), oldest play first, or without eventId the list of games that have play-by-play. For soccer the plays are the match incidents: goals, own goals, penalties, cards and substitutions. | Access: All paying tiers | Sports: nba, ncaaf, nfl, nhl, soccer | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mlb, mma, ncaab, table_tennis, tennis, valorant, volleyball, wnba | Query: eventId (optional) - The game, as GET /api/v1/{sport}/scores/live names it. Omit it to list the games that have play-by-play. | Query: since (optional) - Return the plays whose seq is greater than this (pass the lastSeq of your previous response), plus any play amended in the last 2 minutes. A non-negative integer; anything else is a 400. | Query: limit (optional) - Return only the last n plays (1 to 5000), after since is applied. Anything else is a 400. | Response: { success, sport, eventId, status, state, home, away, count, lastSeq, plays: [{ id, seq, period, clock, secondsRemaining, team, type, text, scoringPlay, homeScore, awayScore, wallclock, detail, firstSeenAt, updatedAt, withdrawn?, replaces? }], meta: { source, asOf, ageSeconds, sourceAgeSeconds, since, amendedWindowSeconds, returned, truncated, timestamp } }; without eventId: { success, sport, count, games: [{ eventId, home, away, state, playCount, asOf }], meta: { timestamp } } | Note: eventId is the id GET /api/v1/{sport}/scores/live gives the game. Play-by-play exists while a game is in play and for 6 hours after it ends; before tip-off, and for any other id, the response is 404 with a hint. Without eventId the response lists the games that have play-by-play, with their state and play count. | Note: Each play carries the source's own id and order (seq, ascending, not contiguous), the period and the time left in it (clock as the venue shows it, secondsRemaining), team (home or away: the offense, the shooter's team, the team that committed the penalty; null for a neutral play such as a period start), type (the source's own type key, unchanged: NFL and NCAAF Rush, Pass Reception, Punt, Field Goal Good; NHL shot-on-goal, faceoff, hit, penalty, goal; NBA 2pt/Jump Shot, rebound/defensive, turnover/bad pass, freethrow/1 of 2), text, scoringPlay, the score after the play (homeScore, awayScore), wallclock (when the play happened per the source; null for the NHL, which publishes none) and detail, the sport's own fields. | Note: detail for NFL and NCAAF: drive, down, distance, yardLine, yardsToEndzone, downDistanceText, possessionText before the play, endDown, endDistance, endYardLine, endYardsToEndzone, endDownDistanceText after it, yards, scoreValue, isPenalty, isTurnover. For the NHL: situationCode (skaters and goalies on the ice, the league's code), zone, x, y, shotType, reason, penalty {descKey, typeCode, duration}, players [{role, id, name, sweater}] with the roles scorer, assist, shooter, goalie, winner, loser, hitter, hittee, blocker, player, committedBy, drawnBy, servedBy, and homeShotsOnGoal and awayShotsOnGoal on shots; the NHL feed scores only goal plays, so every other play carries the running tally. For the NBA: subType, descriptor, qualifiers, playerId, playerName, teamTricode, shotResult (Made or Missed), shotDistance (feet), x, y, isFieldGoal, possession. For soccer: minute, addedMinutes (stoppage time, e.g. 2 for 45+2), player {id, name}, assist (on a goal), playerOut (on a substitution, whose player is the one coming on), card (yellow, red or yellow-red), note, code; clock is the match minute as shown (45+2), seq is derived from the period and minute (the source has no sequence of its own, so an inserted or withdrawn incident renumbers only the plays of its own minute), secondsRemaining and wallclock are null, period is the half (1, 2; 3 and 4 extra time; 5 penalties), type is the incident label (Goal, Own goal, Penalty, Yellow Card, Red Card, Substitution) and team the side credited with it, the scoring side on an own goal; every soccer game on /scores/live is covered, whatever its league. | Note: Two stamps on every play: firstSeenAt is when the play was first read, under that id, and never moves (for the NHL and soccer, which publish no event time, it is the nearest thing to one: the play reached us within the reader's cadence and the source's edge cache of happening); updatedAt is when the play's current content was first seen and moves on every amendment (a review, an assist added, a scorer corrected), so merge plays by id. A play the source withdraws (a goal overturned on review; a shot the NHL replaces with a goal under a new id) is sent once more with withdrawn: true (updatedAt the read that found it gone), stays in the log for 10 minutes so a since poller meets it, then is dropped; count counts live plays only. The NHL publishes a goal first as a shot, then withdraws the shot and lists the goal under a new id within about 20 seconds: the goal then carries replaces, the withdrawn shot's id, and the goal is the play to keep. since= returns the plays after that seq plus any play amended or withdrawn in the last 2 minutes whatever its seq (meta.amendedWindowSeconds), so a poller that passes the lastSeq it has every two minutes or sooner sees every amendment; limit= returns the last n plays, applied after since. A since or limit that is not an integer in range is a 400. meta.returned and meta.truncated say what the filters did. | Note: Soccer also carries phase markers, recorded as the reader observes each change on the live board and in the incidents feed (which opens a period's header the moment it starts): type period-start (Kick-off, Second half under way, Extra time under way, Second period of extra time under way, Penalty shoot-out under way), period-end (Half-time, End of the second half, the ends of the extra-time periods and of the shoot-out) and game-end (Full-time), with ids phase:p1:start, phase:p1:end ... phase:end, team null, the match minute as shown when the board had one, the score at that moment, and firstSeenAt the observation. They sort before and after the incidents of their period. A start is recorded only when the reader sees the period within its first minutes: a match first seen in its 20th minute gets no kick-off marker, a period whose incidents already run minutes in when its header appears gets no start marker (nor its predecessor an end marker: that break was not observed either), and a reader restart records nothing late. A period's end is recorded when the reader sees the break, or on the start marker of the next period; the end of the match is always recorded, on the read that confirms it. | Note: meta.asOf is when the source was read and meta.ageSeconds how long ago that was; status is stale when a game in play has not been read for 30 seconds (the reader runs every few seconds). meta.sourceAgeSeconds is the source's own edge-cache age at that read, null when it sends none: NHL plays can be up to about 20 seconds behind the ice, NFL and NCAAF plays about 7 seconds, NBA plays are not cached. Soccer incidents are read every 20 seconds per match and at once when the match score changes, so a goal shows within a few seconds and a card or substitution within about 20; a soccer game is stale after 60 seconds without a read. | Note: The same plays are pushed on the plays-update WebSocket event, opt-in with plays in the subscription (true, or an array of nba, nhl, nfl, ncaaf, soccer), same access as scores-update: one message per sport within a few seconds of a new play, carrying only the plays new or amended since the previous message. | SDK: ts getPlays() / py get_plays() #### GET /api/v1/{sport}/scores/live | Returns live games for one sport with current score, period and status. | Access: All paying tiers | Sports: cricket, cs2, darts, lol, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, valorant | Not available for: dota2, handball, khl, mma, volleyball, wnba | Response: { success, sport, timestamp, count, events: [...] }; when nothing is live the response may add hasScheduledGames, gameInfo and seasonInfo describing the slate. | Note: Score changes for games in play are also pushed on the scores-update WebSocket event, opt-in with scores in the subscription, using this endpoint's event ids. That event carries a lean view (status with the clock, teams, score); this endpoint has the full event. A push fires when the score, the state, the period or the running flag of the clock changes, never on every clock tick. | Note: An empty events array with hasScheduledGames true means games are scheduled but none has started. | Note: NFL, NCAAF and NBA give the period in status.period and its label in status.detail: Q1 to Q4, Halftime (period 2), Intermission (any other break between periods) or OT (period 5 for every overtime; overtimes are not numbered). NHL gives P1 to P3, Intermission between periods (status.period stays the period just played), OT (period 4 for every overtime) or SO (period 5). While one of these games is in play, status.displayClock is the time left in the period as the venue shows it (5:32, or 48.3 under a minute; 0:00 in an intermission) and status.clock carries it as secondsRemaining (0 in an intermission) with running (true while it runs, false in a stoppage or intermission, null when the feed cannot say: NFL and NCAAF between plays, and any game before its first movement is seen), intermission (halftime or between periods), period and asOf, the instant the clock was true at the venue (the read, less the league feed's own edge-cache age, which for the NHL is up to 19 seconds): tick secondsRemaining down from asOf locally while running. While status.clock is present, status.period and status.detail are taken from the same clock read, so the three always agree; the clock is read every few seconds and is absent when none was read for the game. Soccer status.detail is the half (1st Half, 2nd Half; the match minute is status.displayClock, e.g. 67 or 45+2), Half Time (status.period 1), Extra Time (status.period null: the feed does not say which of its two periods is on; the play-by-play does), Break (between the periods of extra time, status.period null) or Penalties (status.period 5). A phase without a label reads Live with status.period null, as does college basketball. MLB gives the inning in status.period and status.detail (e.g. Top 7th); in extra innings status.detail reads Top Extra Inning, Bot Extra Inning, or Extra Inning when the half is unknown, with status.period null, because extra innings are not numbered. On /scores/live, a baseball linescore period 10 is every extra inning combined. | SDK: ts getScores() / py get_scores() #### GET /api/v1/scores/live | Returns live games across every supported sport in one call, with current score, period and status. | Access: All paying tiers | Response: { success, data: { timestamp, sports: { : [events] } } } | Note: Score changes for games in play are also pushed on the scores-update WebSocket event, opt-in with scores in the subscription, using this endpoint's event ids. That event carries a lean view (status with the clock, teams, score); this endpoint has the full event. A push fires when the score, the state, the period or the running flag of the clock changes, never on every clock tick. | Note: 503 until the first scoreboard has been collected. | Note: NFL, NCAAF and NBA give the period in status.period and its label in status.detail: Q1 to Q4, Halftime (period 2), Intermission (any other break between periods) or OT (period 5 for every overtime; overtimes are not numbered). NHL gives P1 to P3, Intermission between periods (status.period stays the period just played), OT (period 4 for every overtime) or SO (period 5). While one of these games is in play, status.displayClock is the time left in the period as the venue shows it (5:32, or 48.3 under a minute; 0:00 in an intermission) and status.clock carries it as secondsRemaining (0 in an intermission) with running (true while it runs, false in a stoppage or intermission, null when the feed cannot say: NFL and NCAAF between plays, and any game before its first movement is seen), intermission (halftime or between periods), period and asOf, the instant the clock was true at the venue (the read, less the league feed's own edge-cache age, which for the NHL is up to 19 seconds): tick secondsRemaining down from asOf locally while running. While status.clock is present, status.period and status.detail are taken from the same clock read, so the three always agree; the clock is read every few seconds and is absent when none was read for the game. Soccer status.detail is the half (1st Half, 2nd Half; the match minute is status.displayClock, e.g. 67 or 45+2), Half Time (status.period 1), Extra Time (status.period null: the feed does not say which of its two periods is on; the play-by-play does), Break (between the periods of extra time, status.period null) or Penalties (status.period 5). A phase without a label reads Live with status.period null, as does college basketball. MLB gives the inning in status.period and status.detail (e.g. Top 7th); in extra innings status.detail reads Top Extra Inning, Bot Extra Inning, or Extra Inning when the half is unknown, with status.period null, because extra innings are not numbered. On /scores/live, a baseball linescore period 10 is every extra inning combined. | SDK: ts getScores() / py get_scores() Sport in the path, not a query parameter; the two routes have different envelopes (Response lines above). LiveScoreEvent: { id, sport, name, startTime, status: { state, detail, displayClock, period }, home, away, league, lastUpdated } with home / away = { homeAway, score, team: { displayName, abbreviation?, logoUrl? }, linescores? } (per-period scoring). Events may add matchStats, incidents, playerStats, lineups, h2h and per-sport detail blocks. team.logoUrl (500px PNG) and team.abbreviation come from an exact-name lookup covering NBA, NHL, MLB and NCAA basketball and are set independently, so null-check both; soccer, tennis and esports have neither. Logos appear on scores, schedule and results only, never on odds. ## [schedule-api] Schedule and results (docs: https://owlsinsight.com/docs#schedule-api) #### GET /api/v1/{sport}/results | Returns completed games for a sport with their final scores. | Access: All paying tiers | Sports: cricket, darts, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, wnba | Not available for: cs2, dota2, handball, khl, lol, mma, valorant, volleyball | Response: { success, sport, timestamp, count, events: [...] } | Note: 503 when the results source is unavailable; retry shortly. | SDK: ts getResults() / py get_results() #### GET /api/v1/{sport}/schedule | Returns upcoming games for a sport: matchups, start times and competition, before they go live. | Access: All paying tiers | Sports: cricket, darts, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, wnba | Not available for: cs2, dota2, handball, khl, lol, mma, valorant, volleyball | Response: { success, sport, timestamp, count, events: [...] } | Note: 503 when the schedule source is unavailable; retry shortly. | SDK: ts getSchedule() / py get_schedule() #### GET /api/v1/schedule | Returns every game on the schedule for a calendar day in your time zone, across NFL, college football, MLB, NBA, WNBA, NHL, tennis, soccer, UFC, League of Legends, CS2, Valorant and Dota 2: start time, status, score and clock, both sides, and Kalshi win prices. | Access: All paying tiers | Query: date (optional) - Calendar day as YYYY-MM-DD in the tz zone. Defaults to the current date in that zone. | Query: tz (optional) - IANA time zone that defines the day, for example America/Los_Angeles. Defaults to America/New_York. An unknown zone returns 400. | Query: days (optional) - Number of days from date, 1 to 10. Defaults to 1. | Query: sport (optional) - Comma separated: nfl, ncaaf, mlb, nba, wnba, nhl, tennis, soccer, ufc, lol, cs2, valorant, dota2. Defaults to all. An unknown sport returns 400 with the supported list. | Response: { success, date, days, tz, sports, count, games: [{ id, sport, league, competition, round, startTime, status, statusDetail, competitors: [{ side, name, short, code, score, sets, winner, winPrice }], drawPrice, kalshiEventTicker, eventId, startCheck: { sportsbooks, flashscore, differsByMinutes, sportsbooksMinutes, flashscoreMinutes }, liveUpdatedAt, volume, updatedAt }], meta: { source, status, cacheTimestamp, ageSeconds, liveAgeSeconds, window, buildWindow, timestamp } } | Note: status is one of scheduled, in_progress, delayed, final, postponed, cancelled, time_tbd, unconfirmed. unconfirmed means the scheduled start passed long ago and no live data confirms the game started or ended, or live data for a game in progress stopped updating for more than 10 minutes. | Note: eventId is the matching /odds eventId when the game was found there, otherwise null. | Note: Esports (lol, cs2, valorant, dota2): score is maps won, side is null and the two competitors come in Kalshi's title order. statusDetail is Map N only while that map is being played (Delayed when delayed), otherwise null until the match is final. startCheck is present with both sources missing (no sportsbook start to check against), and eventId is always null for esports. A ?sport= filter with no esport ignores the esports list in meta.status. | Note: winPrice is the Kalshi price to buy that side (0 to 1), so the two sides can add to a little over 1. It is null once the market closes, and null when the market is too thin to price (the asks add up past 1.15, or 1.20 with a draw). | Note: meta.status is no-data (nothing built for the range), stale (built more than 3 minutes ago, the schedule not refreshed for 5 minutes, or live scores more than 90 seconds old while a game has started and not finished), partial (the range reaches outside buildWindow, which runs from three UTC days back to nine UTC days ahead, a day in range was not built, or the schedule list behind the build was cut short), or ok, checked in that order. | SDK: ts getDaySchedule() / py get_day_schedule() The current calendar day only: schedule is games not yet started (status.state "pre"), results is finished games with final scores ("post"); past days are in [history-api]. Events use the LiveScoreEvent shape from [scores-api]. Soccer events carry the competition in event.league ("ENGLAND: Premier League"); soccer and tennis return the global slate, so filter on event.league client-side. A sport between seasons returns count 0 with hasScheduledGames false. ## [stats-api] Player stats (docs: https://owlsinsight.com/docs#stats-api) #### GET /api/v1/{sport}/stats | Returns per-game team stats, score and status for every game on a date: live, scheduled and finished. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Query: date (optional) - Game date as YYYY-MM-DD or YYYYMMDD. Defaults to the current date in US Eastern time. | Response: { success, data: [{ eventId, name, startTime, status: { state, detail, displayClock, period, clock? }, score: { home, away } | null, soccerDetail?, lastUpdated, teamStats }], meta: { sport, date, gameCount, timestamp, scheduledAndFinishedAsOf, notice? } } | Note: Covers the current US Eastern day's matches: live, scheduled and finished (a finished match stays for 24 hours from its start). Use the history endpoints for older games. | Note: date is compared with each game's start time in US Eastern time. | Note: score is null for a scheduled game. For soccer it never includes penalty shootout goals: soccerDetail carries regulation, extraTime, penalties and decidedBy. | Note: NFL, NCAAF and NBA give the period in status.period and its label in status.detail: Q1 to Q4, Halftime (period 2), Intermission (any other break between periods) or OT (period 5 for every overtime; overtimes are not numbered). NHL gives P1 to P3, Intermission between periods (status.period stays the period just played), OT (period 4 for every overtime) or SO (period 5). While one of these games is in play, status.displayClock is the time left in the period as the venue shows it (5:32, or 48.3 under a minute; 0:00 in an intermission) and status.clock carries it as secondsRemaining (0 in an intermission) with running (true while it runs, false in a stoppage or intermission, null when the feed cannot say: NFL and NCAAF between plays, and any game before its first movement is seen), intermission (halftime or between periods), period and asOf, the instant the clock was true at the venue (the read, less the league feed's own edge-cache age, which for the NHL is up to 19 seconds): tick secondsRemaining down from asOf locally while running. While status.clock is present, status.period and status.detail are taken from the same clock read, so the three always agree; the clock is read every few seconds and is absent when none was read for the game. Soccer status.detail is the half (1st Half, 2nd Half; the match minute is status.displayClock, e.g. 67 or 45+2), Half Time (status.period 1), Extra Time (status.period null: the feed does not say which of its two periods is on; the play-by-play does), Break (between the periods of extra time, status.period null) or Penalties (status.period 5). A phase without a label reads Live with status.period null, as does college basketball. MLB gives the inning in status.period and status.detail (e.g. Top 7th); in extra innings status.detail reads Top Extra Inning, Bot Extra Inning, or Extra Inning when the half is unknown, with status.period null, because extra innings are not numbered. On /scores/live, a baseball linescore period 10 is every extra inning combined. | Note: A teamStats pair the source did not measure is null or absent, never 0. | SDK: ts getStats() / py get_stats() #### GET /api/v1/{sport}/stats/averages | Returns a player's rolling averages over the last 5, 10 and 20 games from published game logs, optionally restricted to games against one opponent. | Access: Rookie and above | Sports: nba, ncaab | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mlb, mma, ncaaf, nfl, nhl, soccer, table_tennis, tennis, valorant, volleyball, wnba | Query: playerName (required) - Player name, up to 100 characters. | Query: opponent (optional) - Opponent team, up to 100 characters. Limits the averages to head-to-head games. | Response: { success, data: { player, team, averages: { L5, L10, L20 }, ... } } | Note: Fetched fresh on every request. | Note: 404 when the player is not found, has no game log for the season, or has no games against the opponent. | SDK: ts getPlayerAverages() / py get_player_averages() #### GET /api/v1/{sport}/stats/h2h | Returns head-to-head history and recent form for one scoreboard event. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Query: eventId (required) - Event id from the live scores or /stats response. | Response: { success, data: { eventId, sport, h2h } } | Note: 404 when the eventId is not on the live scoreboard or has no head-to-head data. | SDK: ts getH2H() / py get_h2h() #### GET /api/v1/{sport}/stats/match | Returns full detail for one event: score, status, team stats, lineups and incidents. | Access: Rookie and above | Sports: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cricket, cs2, darts, dota2, handball, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Query: eventId - as on GET /api/v1/{sport}/stats/h2h | Response: { success, data: { eventId, sport, name, startTime, status, score, soccerDetail?, lastUpdated, teamStats, lineups: { home, away }, incidents, tennisStats? }, meta: { sport, timestamp } } | Note: Covers live matches and the current US Eastern day's scheduled and finished matches; 404 otherwise. | SDK: ts getMatchStats() / py get_match_stats() Box scores for recent and live games, rolling averages (last 5, 10, 20 games and season), match statistics and head-to-head history, keyed by the same eventId the odds and scores endpoints use. Archived box scores are in [history-api]. ## [prediction-markets] Prediction markets (docs: https://owlsinsight.com/docs#prediction-markets) Kalshi and Polymarket: Source API only, /api/v2/kalshi/{sport}?league= and /api/v2/polymarket/{sport}?league=, prices as probabilities; their v1 routes are retired (410, replacements listed in [odds-api]); Kalshi props also appear in the merged /api/v1/{sport}/props with a liquidity object. Novig: main lines inside /api/v1/{sport}/odds with exchange: true, full ladder on /api/v2/novig/{sport}. ProphetX: its own route and event below, never inside /odds. Fanatics Markets: /api/v2/fanaticsmarkets/{sport}, a Nadex-settled prediction market, not the Fanatics sportsbook. 1xBet soccer: isolated on GET /api/v1/1xbet/soccer and the 1xbet-update event (oneXBetSoccer: true), not inside /odds; 1xBet esports stay inside /odds. ## [sub-prophetx-endpoints] ProphetX (docs: https://owlsinsight.com/docs#sub-prophetx-endpoints) #### GET /api/v1/prophetx/odds | Returns raw ProphetX exchange markets for one or more sports, with a deep link added to each event and to each outcome. | Access: All paying tiers | Query: sport (required) - ProphetX sport name, or a comma-separated list of them. [values: basketball, baseball, ice_hockey, tennis, soccer, golf, american_football, mma, boxing, motorsport, cross_sport] | Query: kind (optional) - Restrict to game markets or player prop markets. [values: game, prop] | Note: ProphetX fields pass through unchanged. event_link (per event) and outcome_link (per outcome, only when the ids needed for a working link are present) are added. | Note: 503 when the ProphetX feed is unavailable; retry shortly. | SDK: ts getProphetxOdds() / py get_prophetx_odds() Peer-to-peer exchange with every market, every line and liquidity per outcome. ?sport= is required and takes ProphetX's own slugs, comma-separated: basketball, baseball, ice_hockey, soccer, tennis, golf, american_football, mma, boxing, motorsport, cross_sport; ?kind=game|prop filters by kind. prophetx-update streams the same shape: prophetx: true for the firehose or prophetx: { sports: ["basketball"], kinds: ["game"] } to filter server-side. ## [history-api] Historical data (docs: https://owlsinsight.com/docs#history-api) #### GET /api/v1/history/{sport}/matches | Lists archived esports match results with map scores, filterable by team, event, date, star rating and LAN. | Access: MVP and above | Sports: cs2 | Not available for: cricket, darts, dota2, handball, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, valorant, volleyball, wnba | Query: team (optional) - Team name; partial match. | Query: event (optional) - Event or tournament name; partial match. | Query: startDate (optional) - Earliest game date to include, YYYY-MM-DD. | Query: endDate (optional) - Latest game date to include, YYYY-MM-DD. | Query: stars (optional) - Minimum star rating, 1 to 5. | Query: lan (optional) - Set to true to return LAN matches only. | Query: limit (optional) - Page size. Default 50, maximum 100. | Query: offset (optional) - Number of rows to skip, for pagination. | Response: { success, data: { matches: [...], pagination: { total, limit, offset, hasMore } } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | SDK: ts getCS2Matches() / py get_cs2_matches() #### GET /api/v1/history/{sport}/matches/{matchId} | Returns one archived esports match in full: maps played and per-player statistics. | Access: MVP and above | Sports: cs2 | Not available for: cricket, darts, dota2, handball, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, valorant, volleyball, wnba | Response: { success, data: { match, maps: [...], players: [...] } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: matchId is numeric; anything else returns 400. | SDK: ts getCS2Match() / py get_cs2_match() #### GET /api/v1/history/{sport}/players | Searches archived esports player performances across matches, per map. | Access: MVP and above | Sports: cs2 | Not available for: cricket, darts, dota2, handball, khl, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, table_tennis, tennis, valorant, volleyball, wnba | Query: playerName (optional) - Player name; partial match. | Query: mapName (optional) - Restrict to one map, for example Mirage. | Query: minRating (optional) - Minimum rating. | Query: team, event, startDate, endDate, limit, offset - as on GET /api/v1/history/{sport}/matches | Response: { success, data: { players: [...], pagination: { total, limit, offset, hasMore } } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: At least one of playerName or team is required. | SDK: ts getCS2Players() / py get_cs2_players() #### GET /api/v1/history/closing-odds | Returns historical closing odds per sportsbook: moneyline, spread and total at close for archived games, with the provenance of each row. | Access: MVP and above | Query: eventId (optional) - Archived game identifier. Optional when sport plus a narrowing filter is given. | Query: sport (optional) - Filter by sport key. Required when eventId is omitted. [values: nba, ncaab, nfl, nhl, ncaaf, mlb, cs2, soccer, tennis] | Query: book (optional) - Restrict to one sportsbook. [values: pinnacle, pinnacle_mqtt, ps3838, fanduel, draftkings, betmgm, bet365, caesars, hardrock, 1xbet, kalshi, polymarket, novig, betonline, circa, south_point, westgate, wynn, stations, betr, bovada, 5dimes, sportsbetting_ag, mybookie, topbet, gtbets, statsllc, consensus] | Query: source (optional) - Restrict to one provenance. [values: published-close, live-derived, archive-1, archive-2, archive-3] | Query: season (optional) - Season label, for example 2024-25. Only sports with a labelled season match; where none is recorded the filter matches nothing, so narrow with startDate or book instead. | Query: startDate, endDate, limit, offset - as on GET /api/v1/history/{sport}/matches | Response: { success, data: { odds: [{ eventId, book, source, moneyline: { home, away, draw? }, spread, total, ... }], pagination: { total, limit, offset, hasMore } }, dataQuality } | Note: Either eventId or sport is required, and sport alone is rejected as too broad: add startDate, book, source or season. | Note: Rows from different sources coexist for the same event and book and are not interchangeable: published-close is the book's final published closing price, live-derived is the last snapshot captured at or before the start time (only as close to the close as capture ran) and exists only for games with a known start time from 2026-09-16, and archive-1, archive-2 and archive-3 are third-party historical archives kept distinct because several can coexist for one event and book. | Note: Three-way moneylines carry a draw key; two-outcome sports omit the key entirely rather than sending null. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | SDK: ts getClosingOdds() / py get_closing_odds() #### GET /api/v1/history/coverage | Reports the date range held per sport for each historical dataset, so you can see how far back the history goes before querying it. | Access: All paying tiers | Response: { success, data: { datasets: { : { description, sports: { : { earliest, latest } } } }, notes, generatedAt }, meta: { cached, cacheTtlSeconds } } | SDK: ts getHistoryCoverage() / py get_history_coverage() #### GET /api/v1/history/game-stats-detail | Returns the archived stats detail for one completed game: team stats, lineups, head-to-head and incidents as captured at the final whistle. | Access: MVP and above | Query: eventId (required) - Archived game identifier from /history/games. | Response: { success, data: { eventId, sport, gameDate, homeTeam, awayTeam, teamStats, lineups, h2h, incidents, recordedAt } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds. This endpoint returns a single game's detail for either id, which may be the swapped game, so check the eventId in the response. | SDK: ts getGameStatsDetail() / py get_game_stats_detail() #### GET /api/v1/history/games | Lists archived games with scores and the number of odds, props and player-stat rows held for each. | Access: MVP and above | Query: sport (optional) - Filter by sport key. [values: same list as sport on GET /api/v1/history/closing-odds] | Query: season (optional) - Season label, for example 2024-25. | Query: team (optional) - Team name; matches home or away. | Query: gameType (optional) - Game type. [values: regular, playoff, allstar, preseason, playin] | Query: startDate, endDate, limit, offset - as on GET /api/v1/history/{sport}/matches | Response: { success, data: { games: [{ eventId, sport, homeTeam, awayTeam, homeTeamId?, awayTeamId?, gameDate, commenceTime?, homeScore?, awayScore?, periodScores?, season?, gameType?, venue?, archivedAt, source, oddsSnapshots, propsSnapshots, playerStats, gradedResults }], pagination: { total, limit, offset, hasMore } } } | Note: At least one of sport, season, team or startDate is required. | Note: commenceTime is the scheduled start in UTC. Present when recorded; /history/odds rows with recordedAtUtc before it are pre-game. | Note: homeScore and awayScore are present when the final score was captured at archive, absent otherwise; periodScores, when present, lists each period by label (Q1, OT1, or the inning or period number) with both sides. | Note: homeTeamId and awayTeamId are the roster ids the odds endpoints use (home_team_id, away_team_id); absent when a name is not on our roster. | Note: oddsSnapshots counts every stored row, including rows /history/odds filters out for unusable prices, so it can exceed what that endpoint returns. | Note: playerStats: rows on /history/stats. gradedResults: graded results on /api/v1/{sport}/props/results?game_id=, counted at request time. | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | SDK: ts getHistoryGames() / py get_history_games() #### GET /api/v1/history/odds | Returns the odds snapshots recorded for an archived game, optionally narrowed to a book, market and side, or reduced to opening lines. | Access: MVP and above | Query: book (optional) - Restrict to one sportsbook; also accepted as books, one value. [values: pinnacle, pinnacle_mqtt, ps3838, fanduel, draftkings, betmgm, bet365, caesars, hardrock, 1xbet, kalshi, polymarket, novig, betonline, circa, south_point, westgate, wynn, stations, betr, bovada] | Query: market (optional) - Restrict to one market; also accepted as markets, one value. [values: same list as market on GET /api/odds/history] | Query: side (optional) - Restrict to one side; also accepted as sides, one value. [values: same list as side on GET /api/odds/history] | Query: startTime (optional) - Earliest snapshot to include: ISO 8601 date or Unix timestamp in milliseconds. Compared with the capture time as stored, which runs 4 to 5 hours behind UTC for many rows stored before 2026-09-16. | Query: endTime (optional) - Latest snapshot to include: ISO 8601 date or Unix timestamp in milliseconds. Compared with the capture time as stored, which runs 4 to 5 hours behind UTC for many rows stored before 2026-09-16. | Query: limit (optional) - Page size. Default 1000, maximum 5000. | Query: opening (optional) - Set to true to return only the first recorded snapshot per book, market and side (the opening lines). | Query: offset - as on GET /api/v1/history/{sport}/matches | Query: eventId - as on GET /api/v1/history/game-stats-detail | Response: { success, data: { eventId, opening, timeRange: { start, end }, snapshots: [{ book, market, side, price, priceDecimal, point, pointValue, recordedAt, recordedAtUtc, inPlay? }], count, limit, offset, dataQuality }, meta: { source?, archiveBuiltAt?, ignored_params?, ignored_reasons? } } | Note: Each row is the book main line for that market and side at that moment: one line per book, market and side. Alternate rungs and team totals are not stored; a period market (first_half_h2h, first_half_spreads, first_half_totals) is stored under its own key, never under totals or spreads. Rows recorded before the one-line rule was enforced at capture, and rows from the pinnacle_mqtt source, can carry such lines under totals or spreads for some books; dataQuality.mainLineNote repeats this on every response. | Note: inPlay is true when the game was in play when the row was captured and false when it was pre-game. It is absent when the phase was not recorded: every row captured before it was introduced, every cs2 row, and ps3838 or older pinnacle_mqtt rows. | Note: To keep pre-game rows only, compare recordedAtUtc against commenceTime from /history/games; where inPlay is present it is the same test as the book reported it, and is right on a delayed start where the clock alone is not. | Note: recordedAt is the time as stored, US Eastern for most rows stored before 2026-09-16 despite the Z; recordedAtUtc is the UTC capture time, null where unknown (most rows before 2026-03-28, pinnacle before 2026-04-10). | Note: pinnacle_mqtt and ps3838 are the real-time Pinnacle sources; pinnacle is the polled book. They are stored as separate books; pinnacle_mqtt holds tennis pre-game from 2026-09-23, from the real-time feed. | Note: priceDecimal carries the exact upstream decimal price for decimal-native books and is null otherwise. | Note: meta.source is archive when a completed game is served from the archive copy and live when it is read on request; the payload shape is identical either way, and meta.archiveBuiltAt says when the archive copy was built. | Note: pointValue is point as a number (null when the market has no point); point itself keeps the type it always had. | Note: Rows whose price could not be stored accurately are excluded; dataQuality explains the filter on every response. | Note: A plural spelling (books, markets, sides) carrying a comma-separated list or an unknown value is not applied; it is reported in meta.ignored_params with the reason. Each filter takes one value; the singular spelling with an unknown value returns 400. | Note: Query parameters this endpoint does not read are reported in meta.ignored_params rather than rejected; a value the endpoint read but could not apply (an unknown market, a comma list where one value is allowed, a book alias with no valid key) is reported too, with the reason in meta.ignored_reasons. | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | SDK: ts getHistoryOdds() / py get_history_odds() #### GET /api/v1/history/player-props | Returns historical closing player prop lines per sportsbook from the backfill archive: the line and over/under prices at close for each player and prop type. | Access: MVP and above | Query: eventId (optional) - Archived game identifier. | Query: player (optional) - Player name; case-insensitive partial match. | Query: propType (optional) - Restrict to one prop type. [values: points, rebounds, assists, threes, threes_made, steals, blocks, turnovers, field_goals, points_assists, pts_asts, points_rebounds, pts_rebs, points_rebounds_assists, pts_rebs_asts, assists_rebounds, rebs_asts, steals_blocks, double_double, triple_double, hits, runs, rbis, home_runs, total_bases, singles, doubles, triples, strikeouts, strikeouts_pitcher, strikeouts_batter, stolen_bases, hits_allowed, walks, walks_allowed, earned_runs, earned_runs_allowed, outs_recorded, hits_runs_rbis, to_record_hit, extra_base_hits, saves, goals, hockey_assists, hockey_points, shots_on_goal, goals_against, power_play_points, passing_yards, passing_tds, rushing_yards, rushing_tds, receiving_yards, receiving_tds, receptions, touchdowns, interceptions, rush_attempts, pass_attempts, pass_completions, shots_on_target, tackles, passes, fouls_committed, corners_taken] | Query: book (optional) - Restrict to one sportsbook. [values: draftkings, caesars, betmgm, espnbet] | Query: isMain (optional) - true returns only the line the book was leading with, false only its alternate rungs. Rows whose status was never recorded are excluded by either value, so omit this to get everything. [values: true, false] | Query: startDate, endDate, limit, offset - as on GET /api/v1/history/{sport}/matches | Query: sport - as on GET /api/v1/history/games | Response: { success, data: { props: [{ eventId, sport, playerName, propType, book, gameDate, isMain, closing: { capturedAt, line, overPrice, underPrice, americanPrice }, opening?: { line, overPrice, underPrice, americanPrice } }], pagination: { total, limit, offset, hasMore } } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: At least one of eventId, player or startDate is required; sport alone is rejected as too broad. | Note: A player search that matches an unusually large number of distinct names is treated as a scan and served through a slower fallback with a tighter timeout. | Note: The line and the prices live under `closing`, not at the top level of the row, and the player field is `playerName`. `opening` is present only on rows where an opening line was also captured, so treat it as optional. | Note: A row carries EITHER a labelled pair or a single unlabelled price. `overPrice` and `underPrice` are set when the source stated which side each price belongs to. `americanPrice` is set when it published one price without naming a side. So null side prices mean the row was published one-way, NOT that the price is missing: on one NBA event of 1,133 rows, every row carried a price and 1,057 of them carried it as `americanPrice`. | Note: Do not compare a one-way `americanPrice` against a two-way line as though they were quoted the same way. A two-way market splits its margin across both sides while a single published price carries all of it, so the one-way number reads shorter than a two-way line at a comparable number. | Note: One row is one rung of the book ladder. `isMain` is true for the line the book was actually leading with, false for an alternate rung, and null where it is not known for that row. Null is not a hint that it was an alternate: older rows predate the distinction and it cannot be recovered, so filter on `isMain === true` when you want main lines only and expect null rows to be excluded. | Note: An alternate rung carries one price in `americanPrice` and nothing in the side fields, and it reflects the ladder as it stood at the last pre-game capture, so it has no opening of its own. Only the main line carries open-to-close movement. | Note: CHECK `closing.capturedAt` BEFORE TREATING THESE AS CLOSING NUMBERS. A main line is the last quote we hold with BOTH sides priced, and books commonly go one-way as tip approaches, so the two-way number can predate the game by hours. Measured on one NBA game, the median gap between the last quote of any kind and the last two-sided one was 187 minutes. The price is real, it is simply not always recent, and capturedAt is how you tell. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | SDK: ts getHistoricalPlayerProps() / py get_historical_player_props() #### GET /api/v1/history/props | Returns the player prop snapshots recorded for an archived game, optionally narrowed to a player, prop type and book, or reduced to opening lines. | Access: MVP and above | Query: playerName (optional) - Restrict to one player. | Query: propType (optional) - Restrict to one prop type (for example points, rebounds, assists). | Query: book (optional) - Restrict to one sportsbook. [values: same list as book on GET /api/v1/history/odds] | Query: opening (optional) - Set to true to return only the first recorded snapshot per player, prop and book (the opening lines). | Query: offset - as on GET /api/v1/history/{sport}/matches | Query: eventId - as on GET /api/v1/history/game-stats-detail | Query: startTime, endTime, limit - as on GET /api/v1/history/odds | Response: { success, data: { eventId, opening, timeRange, snapshots: [{ playerName, propType, book, line, lineValue, overPrice, underPrice, americanPrice, recordedAt, recordedAtUtc, alternateLines?, qualityFlags }], count, limit, offset, dataQuality? } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: lineValue is line as a number; line itself keeps the type it always had. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | Note: recordedAt is the capture time as stored: fanduel and caesars rows stored before 2026-09-16 06:57:52 hold US Eastern wall time despite the Z. recordedAtUtc is the capture time in UTC, null where the clock is unknown (rows stored before 2026-03-28, and draftkings rows from 2026-03-28 04:00 to 2026-04-10). Time filters read recordedAt. | Note: qualityFlags lists the known issues of a row and is an empty list when none applies. Some NHL, NFL, NCAAF, NBA and MLB rows carry prop_other_market_under_label, prop_two_markets_alternate, prop_two_markets_in_play or prop_market_partly_wrong: their prices are another market than propType and line say, or one of two markets stored alternately under one key. Flagged rows are still returned. data.dataQuality, present when a row on the page is flagged, counts them per flag and explains each one. Leave flagged rows out of models and grading; prop_two_markets_in_play affects only rows recorded after the game started. | SDK: ts getHistoryProps() / py get_history_props() #### GET /api/v1/history/public-betting | Returns historical public betting percentages for archived NBA games: the share of tickets on each side of the spread and of the total. NBA only; no moneyline and no handle or money percentage is held. | Access: MVP and above | Query: eventId (optional) - Archived game identifier. Optional when sport plus startDate is given. | Query: sport (optional) - Filter by sport key. Required when eventId is omitted. [values: same list as sport on GET /api/v1/history/closing-odds] | Query: startDate, endDate, limit, offset - as on GET /api/v1/history/{sport}/matches | Response: { success, data: { betting: [{ eventId, sport, homeTeam?, awayTeam?, gameDate?, spread?: { homePct, awayPct }, total?: { overPct, underPct } }], pagination: { total, limit, offset, hasMore } } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: Either eventId or sport is required, and sport alone must be narrowed with startDate. | Note: Only NBA games carry rows; any other sport returns an empty list. Live handle and ticket splits for every covered sport are on /api/v1/{sport}/splits. | SDK: ts getPublicBetting() / py get_public_betting() #### GET /api/v1/history/splits | Returns the movement history of betting splits: the recorded readings of the handle (money) percent and ticket (bet) percent on each side of the spread, the total and the moneyline of a game, per book, with the line or price shown at that moment. | Access: MVP and above | Query: eventId (optional) - Game identifier: the eventId of the game on /api/v1/{sport}/odds (the event_id on /api/v1/{sport}/splits). Optional when sport plus startDate is given. | Query: sport (optional) - Required when eventId is omitted. One of the splits sports (nfl, ncaaf, mlb, nba, nhl, ncaab, wnba). | Query: book (optional) - Book key as on the live splits (dk, circa, betmgm). Optional. | Query: startDate (optional) - YYYY-MM-DD (UTC), on the time each movement was recorded. Required with sport. | Query: endDate (optional) - YYYY-MM-DD (UTC), inclusive. With sport the window is at most 7 days and defaults to 7 days from startDate. | Query: limit (optional) - Rows per page, max 500, default 100. | Query: offset - as on GET /api/v1/history/{sport}/matches | Response: { success, data: { eventId?, sport?, window?, splits: [{ event_id, sport, book, title, recorded_at, spread?: { away_line, home_line, away_handle_pct, home_handle_pct, away_bets_pct, home_bets_pct }, total?: { line, over_handle_pct, under_handle_pct, over_bets_pct, under_bets_pct }, moneyline?: { away_price, home_price, away_handle_pct, home_handle_pct, away_bets_pct, home_bets_pct } }], pagination: { total, limit, offset, hasMore } } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: Rows have the shape of the entries on /api/v1/{sport}/splits, oldest first, with recorded_at where the live board has as_of; a row is written when a book's figures (percentages, line or price) change, and again after 24 hours without a change, so consecutive rows for a game and book can carry the same figures. Either eventId or sport is required; with sport, startDate is required and the window is at most 7 days. | Note: A market absent from a row was not quoted at that read; a percentage absent from a market (some books publish tickets only) is not zero. | SDK: ts getHistorySplits() / py get_history_splits() #### GET /api/v1/history/stats | Returns archived player box score rows, for one game or for one player across games. | Access: MVP and above | Query: eventId (optional) - Restrict to one archived game. | Query: position (optional) - Restrict to one position. | Query: startDate, endDate, offset - as on GET /api/v1/history/{sport}/matches | Query: sport - as on GET /api/v1/history/games | Query: limit - as on GET /api/v1/history/odds | Query: playerName - as on GET /api/v1/history/props | Response: { success, data: { stats: [box score rows], count, limit, offset } } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Page one event at a time with limit and offset and stop when a page comes back shorter than limit. Do not fan out one request per book or market in parallel. | Note: At least one of eventId or playerName is required. | SDK: ts getHistoryStats() / py get_history_stats() #### GET /api/v1/history/tennis-stats | Returns the archived match statistics for one completed tennis match: serve, return, break point and point totals for both players. | Access: MVP and above | Query: eventId (required) - Archived match identifier from /history/games. | Response: { success, data: { eventId, ..., recordedAt } } with each statistic as { home, away } | Note: Historical endpoints share a per-key in-flight limit that is lower than the general concurrent-request limit. A request beyond it returns 429 with a Retry-After header giving the delay in seconds; wait that long and resend. | Note: 503 with a Retry-After header means the archive could not serve that request: the query was too expensive to run in one request, or the archive read path was busy. Wait the indicated seconds before resending; if it repeats, narrow the request with the filters this endpoint accepts. | Note: Pass the event's eventId field, the {sport}:{away}@{home}-{YYYYMMDD} form. The numeric id field beside it is the sportsbook's own and returns 400 here rather than an empty result. The date in eventId is the UTC start date, except for MLB games starting from 2026-10-11T10:00Z, which carry the US Eastern date; earlier MLB games keep the UTC date. Before that change, an MLB game starting at 8 PM ET or later and the next day's game between the same teams could share one eventId. NBA and NHL stay UTC-dated. NBA has about one pair of games a season sharing an eventId. NHL has about one pair a season of the same teams on one date, home and away swapped: two eventIds, but the /api/v1/history endpoints match both orderings, so each id returns both games' rows. | SDK: ts getHistoryTennisStats() / py get_history_tennis_stats() Every list route must be bounded (the Note lines say how); unbounded queries are rejected. Ask GET /api/v1/history/coverage what is held instead of assuming: it returns per dataset and sport the earliest and latest date present, and depth differs a lot by sport (NBA and MLB reach back years through backfill, the others begin with live archival). Closing player prop lines (/history/player-props) are a backfilled dataset that lags the current slate, with a book mix that varies by season (filter with book); current lines come from /api/v1/{sport}/props. GET /api/odds/history is the legacy line-movement route for live or recent events; archived games use /api/v1/history/odds. Betting splits movement is /api/v1/history/splits: rows mirror the entries on /api/v1/{sport}/splits with recorded_at, oldest first. A row is recorded when any of a book's figures for a game (a percentage, a line or a price) changes, and again after 24 hours without a change, so consecutive rows can carry the same percentages (only a line or price moved, or unchanged figures were recorded again). History begins 2026-09-27 for Circa Sports, 2026-09-29 21:14 UTC for BetMGM and 2026-09-29 23:21 UTC for DraftKings. Queries are bounded by eventId or by sport plus startDate (at most 7 days); /history/public-betting is a different dataset that holds NBA games only. ```typescript const coverage = await client.rest.getHistoryCoverage(); const games = await client.rest.getHistoryGames({ sport: "nba", startDate: "2026-03-01", endDate: "2026-03-07" }); const opening = await client.rest.getHistoryOdds({ eventId: games.data.games[0].eventId, opening: true }); ``` ## [websocket] WebSocket (docs: https://owlsinsight.com/docs#websocket) | Connection URL: https://api.owlsinsight.com (Socket.IO) | Auth: query string ?apiKey=YOUR_API_KEY, i.e. io(url, { query: { apiKey } }); not the auth object and not a header | Transport: ["websocket"] (not polling) | Subscribe payload shape: socket.emit("subscribe", { sports, books, esports, prophetx, oneXBetSoccer, esportsRealtime, v2: { : { : "*" | [leagueKeys] } } }); every field is optional and a later subscribe replaces the earlier one #### WebSocket event: 1xbet-update | Isolated 1xBet soccer odds, pushed on change. 1xBet soccer is not part of odds-update; it has its own REST endpoint and this event. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field oneXBetSoccer: true | Sports: soccer #### WebSocket event: 4casters-v2-update | 4casters Source API (v2) event deltas from a peer-to-peer betting exchange. Each entry is a whole event carrying its full order book: every resting offer with its own price and the size still available on it (sumUntaken), plus matched volume. Alternate spreads and totals appear as keyed rungs, and player props are separate leagues. Odds are American and may carry a half point. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { 4casters: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: 4casters (4casters)" block in [source-api] #### WebSocket event: ballybet-v2-update | Bally Bet Source API (v2) deltas delivered verbatim, in the same shape as betrivers-v2-update because both books run on one trading platform: each entry is a whole event with all of its bet offers. Event ids match BetRivers; prices are Bally Bet's own. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { ballybet: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: Bally Bet (ballybet)" block in [source-api] #### WebSocket event: bet105-v2-update | Bet105 Source API (v2) event deltas from the Ganchrow pandora platform, a reduced-juice Pinnacle mirror carrying both a prematch and an in-play plane. Each entry is a whole event; odds are decimal. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { bet105: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: Bet105 (bet105)" block in [source-api] #### WebSocket event: bet365-v2-update | Bet365 Source API (v2) market deltas: per-market snapshots and outcome patches, possibly chunked. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { bet365: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: Bet365 (bet365)" block in [source-api] #### WebSocket event: betmgm-props-update | BetMGM player props, streamed on change. Subscribe with the subscribe-betmgm-props event; the server acknowledges with betmgm-props-subscribed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: emit the "subscribe-betmgm-props" event #### WebSocket event: betonline-realtime | BetOnline Source API (v2) market deltas, batched per flush cycle. The payload carries a markets array; there is no payload.data. The event name is betonline-realtime, not betonline-v2-update. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { betonline: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: BetOnline (betonline)" block in [source-api] #### WebSocket event: betrivers-v2-update | BetRivers Source API (v2) deltas delivered verbatim. Each entry is a whole event with all of its bet offers rather than a single market. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { betrivers: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: BetRivers (betrivers)" block in [source-api] #### WebSocket event: betus-v2-update | BetUS Source API (v2) event deltas from the book's live platform, which carries both a prematch and an in-play plane. Each entry is a whole event. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { betus: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: BetUS (betus)" block in [source-api] #### WebSocket event: caesars-props-update | Caesars player props, streamed on change. Subscribe with the subscribe-caesars-props event; the server acknowledges with caesars-props-subscribed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: emit the "subscribe-caesars-props" event #### WebSocket event: cs2-realtime | Pinnacle cs2 realtime in the Pinnacle wire format (matchups, markets, prices). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field esportsRealtime: { cs2: { league: '' } } | Sports: cs2 #### WebSocket event: dota2-realtime | Pinnacle dota2 realtime in the Pinnacle wire format (matchups, markets, prices). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field esportsRealtime: { dota2: { league: '' } } | Sports: dota2 #### WebSocket event: draftkings-props-update | DraftKings player props, streamed on change. Subscribe with the subscribe-draftkings-props event; the server acknowledges with draftkings-props-subscribed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: emit the "subscribe-draftkings-props" event #### WebSocket event: draftkings-v2-update | DraftKings Source API (v2) market deltas, one market per message: soccer, WNBA and MMA markets, plus the other US sports' live snapshots unless too large to send; their main catalog is REST only. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { draftkings: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: DraftKings (draftkings)" block in [source-api] #### WebSocket event: esports-update | Esports odds (CS2, Valorant, LoL, Dota 2), pushed on change. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field esports: true | Sports: cs2, dota2, lol, valorant #### WebSocket event: fanaticsmarkets-books-v2-update | Fanatics Markets live order books within about a second of a move: whole books-* entries or patch true (merge by symbol, null = gone), removedIds, and ids (the league's full list). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { fanaticsmarkets: { : '*' | [leagueKeys], books: true } } | Sports: boxing, cs2, dota2, golf, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba #### WebSocket event: fanaticsmarkets-v2-update | Fanatics Markets Source API (v2) contract deltas. A prediction market settled on Nadex: prices are implied probabilities (0 to 1), not odds. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { fanaticsmarkets: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: Fanatics Markets (fanaticsmarkets)" block in [source-api] #### WebSocket event: fanduel-props-update | FanDuel player props, streamed on change. Subscribe with the subscribe-fanduel-props event; the server acknowledges with fanduel-props-subscribed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: emit the "subscribe-fanduel-props" event #### WebSocket event: fanduel-v2-update | FanDuel Source API (v2) market deltas, one (event, tab) bucket per message, across every FanDuel v2 sport. Alternate lines and player prop tabs are included. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { fanduel: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: FanDuel (fanduel)" block in [source-api] #### WebSocket event: kalshi-v2-update | Kalshi prediction market deltas: raw market objects plus orderbook and trade events for game lines, spreads, totals and player-prop series. League keys are Kalshi series tickers from the /leagues index. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { kalshi: { nba: '*' | [seriesTickers] } } | Sports: those on the "Source API book: Kalshi (kalshi)" block in [source-api] #### WebSocket event: lol-realtime | Pinnacle lol realtime in the Pinnacle wire format (matchups, markets, prices). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field esportsRealtime: { lol: { league: '' } } | Sports: lol #### WebSocket event: lowvig-v2-update | LowVig Source API (v2) market deltas, live in-play and pregame. LowVig shares BetOnline's upstream, event ids and market ids but prices differently (reduced juice pregame). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { lowvig: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: LowVig (lowvig)" block in [source-api] #### WebSocket event: mybookie-v2-update | MyBookie Source API (v2) per-event deltas including a removedGhids array so finished games can be dropped from local state. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { mybookie: { mlb: '*' } } | Sports: those on the "Source API book: MyBookie (mybookie)" block in [source-api] #### WebSocket event: odds-update | Consensus odds for the sports and books in your subscription. After a subscribe the first frames are a snapshot of that shape; later frames carry only the games that changed, so merge by event id. A connection that never subscribes receives the default (all sports, all books) after a short grace. To receive only your sports from the first frame, send your subscription in the connection handshake as auth.subscription, the same shape as subscribe: the server acknowledges it with subscribed, and socket.io sends it again on every reconnect. A subscription sent with subscribe belongs to that one connection: send it again after every reconnect. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: sent automatically after connect; narrow it with the subscribe payload | Sports: cricket, darts, handball, khl, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, volleyball, wnba #### WebSocket event: pinnacle-realtime | Pinnacle real-time odds, sent to eligible connections for the sports in your subscription (all sports, after a short grace, if you never subscribe; the books filter does not apply). Each frame carries one sport and at least one event: {timestamp, : [events], delta: true, resync?, stream?, removed?, feed?}. Events have the same fields as the data of GET /api/v1/{sport}/realtime, including status, isLive, phase when known, lastUpdate, seenAt and freshness; that endpoint's notes describe them. Frames are partial: merge each event into your board by event id, replacing the whole event, then delete the ids in removed[]. resync: true marks a frame from the periodic resend, which also carries events that have not changed. When present, stream is {sport, epoch, seq, checkpoint?}: seq goes up by 1 per frame for that sport within one epoch. Ignore a frame whose seq is at or below the last one you applied (a duplicate). A skipped seq, a new epoch, or a frame without stream after frames that had one means you must load the board again; keep your current board until the new one is ready. To load a board, on every connect or reconnect and after any of those: buffer the sport's frames from both pinnacle-realtime and pinnacle-realtime-sync, wait up to 25 seconds for one with stream.checkpoint set (load at once if the sport's frames carry no stream), then call GET /api/v1/{sport}/realtime and read its meta.stream. If meta.stream is null, replace your board with data, merge every frame with no seq filter, and load again after 10, 20, 40 seconds and so on, up to every 5 minutes; replacing the board is also what drops ended events while frames carry no removed. If its epoch differs from the checkpoint's, or its seq is lower than the checkpoint's, load again at the next checkpoint. Otherwise replace your board with data, discard buffered frames with a seq at or below meta.stream.seq, and apply the rest in order. Each load counts against your request quota. Stream positions and removals for a sport with no events to send arrive on pinnacle-realtime-sync. feed is {upstreamAgeSeconds, resubscribing, stalled}, the same fields as meta.feed on GET /api/v1/{sport}/realtime: upstreamAgeSeconds is the time since Pinnacle last sent anything for the sport, and stalled is true when the sport has an event in play (phase live) and Pinnacle has been silent for longer than its normal quiet spells, so prices can be out of date while frames keep arriving. null means unknown. In soccer women's and youth leagues, team names carry a Women or Unn suffix taken from the league, as on the matching REST endpoint. | Access: WebSocket access, plus MVP or above for the REST board. Subscribe: sent automatically after connect; narrow it with the subscribe payload #### WebSocket event: pinnacle-realtime-sync | Companion of pinnacle-realtime, sent on the same connections and for the same sports: a stream position or removals for a sport that has no events to send, as {timestamp, sport, stream?, removed?, feed?}. Track stream and delete removed ids exactly as on pinnacle-realtime, and read feed as described there; it never carries events. | Access: WebSocket access, plus MVP or above for the REST board. Subscribe: sent automatically after connect; narrow it with the subscribe payload #### WebSocket event: pinnacle-v2-update | Pinnacle Source API (v2) per-matchup deltas: changed matchupIds plus the league hash, with a 30 second heartbeat per league (heartbeat: true) whose matchupIds is the full league membership rather than a change set. The hash equals the REST etag (without quotes) whenever that response is not partial, so a hash you already hold needs no refetch; otherwise refetch with If-None-Match, and a 304 on /api/v2 does not count toward the monthly quota. A delta or a heartbeat may also carry removedMatchupIds: matchup ids the league dropped, which you delete. That list is not exhaustive and an id can reappear later in matchupIds, so heartbeat membership and the REST board stay authoritative. When a league empties, its final heartbeat can carry an empty matchupIds; after that the league sends nothing until it has matchups again. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { pinnacle: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: Pinnacle (pinnacle)" block in [source-api] #### WebSocket event: player-props-update | Pinnacle player props, streamed on change. Subscribe with the subscribe-props event; the server acknowledges with props-subscribed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: emit the "subscribe-props" event #### WebSocket event: plays-update | Play-by-play for NBA, NHL, NFL, NCAAF and soccer games in play, pushed within a few seconds of a new play, one message per sport: {type, timestamp, sport, events}. Opt in with plays: true or an array of those sport keys; no opt-in, no messages. For soccer the plays are the match incidents (goals, own goals, penalties, cards, substitutions) of every game on the live board, whatever its league. Each event is one game, {eventId, home, away, state, count, plays}: eventId is the id GET /api/v1/{sport}/scores/live gives the game, count the plays in the game after this message, and plays the plays new or amended since the previous message, in order, each in the shape of GET /api/v1/{sport}/plays (id, seq, period, clock, secondsRemaining, team, type, text, scoringPlay, homeScore, awayScore, wallclock, detail). Merge plays by id: a play can be amended after it is first sent (a review, an assist added). A game that ends is sent once more with state post, with any last plays. There is no full message: fetch GET /api/v1/{sport}/plays?eventId= once to start, then apply the deltas. Delivery follows each league's own feed: NHL plays can be up to about 20 seconds behind the ice, NFL and NCAAF plays about 7 seconds, NBA plays a few seconds; a soccer goal within a few seconds of the score changing, a card or substitution within about 20 seconds. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field plays: true | ['nfl', 'nba', 'soccer'] | Sports: nba, ncaaf, nfl, nhl, soccer #### WebSocket event: polymarket-v2-update | Polymarket CLOB deltas: raw market objects plus price and orderbook changes for game lines and player props. League keys are Polymarket tag slugs from the /leagues index. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { polymarket: { nba: '*' | [tagSlugs] } } | Sports: those on the "Source API book: Polymarket (polymarket)" block in [source-api] #### WebSocket event: propbuilder-v2-update | Prop Builder deltas, pushed within seconds: pregame player props and game markets as changed entries (kind is the family) plus removedIds. Decimal odds. Load the REST board on every connect. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { propbuilder: { nfl: '*' } } | Sports: those on the "Source API book: Prop Builder (propbuilder)" block in [source-api] #### WebSocket event: prophetx-update | ProphetX sports exchange odds and liquidity deltas, pushed on change. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field prophetx: true #### WebSocket event: ps3838-realtime | PS3838 real-time odds, sent to eligible connections for the sports in your subscription (all sports, after a short grace, if you never subscribe; the books filter does not apply). To receive pinnacle-realtime without this event, add realtime: {ps3838: false} to your subscription; update-subscription replaces the realtime object whole. Each frame carries one sport and says how to apply it: with delta: true it holds only the events that changed, so merge them into your board by id; with snapshot: true, or with neither marker, it is the sport's full board, so replace yours. When a sport sends deltas, it also sends a snapshot about every 20 seconds, sometimes sooner after an event leaves the board. A sport with no events sends nothing, so when its last event ends no frame says so: on connect, on reconnect, and whenever a sport has sent nothing for about a minute, load its board from GET /api/v1/{sport}/ps3838-realtime and apply frames from there. A frame can be several MB, so allow a client message size of at least 32 MB. In soccer women's and youth leagues, team names carry a Women or Unn suffix taken from the league, as on the matching REST endpoint. | Access: WebSocket access, plus MVP or above for the REST board. Subscribe: sent automatically after connect; narrow it with the subscribe payload #### WebSocket event: scores-update | Live scores pushed within a few seconds of a change, one message per sport: {type, timestamp, sport, events, removed?}. Opt in with scores: true or an array of sport keys; no opt-in, no messages. Each event is a lean view of one game, {id, sport, name, startTime, status, home, away, lastUpdated}: status is the status object of GET /api/v1/scores/live (status.state is pre, in or post), home and away are {team: {displayName, abbreviation?}, score}, and id is that endpoint's event id. Stats, lineups and incidents are not included; the REST endpoint has them. type full lists every game in play for that sport and comes about every 30 seconds, so a client that joins late has the whole live board within 30 seconds; a sport with no game in play sends no full message. When a game leaves play in the same cycle, its delta comes just before the full. type delta carries only what changed since the previous message: games in play whose score or state changed, games that just finished (status.state post, with the final score), and removed, the ids of games that left the live board without a known final state. Treat a removed id as no longer live; GET /api/v1/scores/live has how it ended. In a delta, each event's lastUpdated is the time of that change. Replace your live board for the sport on a full, and on a delta merge events by id and drop the removed ids. A score change is sent within about 1 second where the source pushes scores and within about 3 seconds otherwise. An array takes the sport keys of the subscription sports list and drops unknown keys; esports and table tennis scores come only with scores: true. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field scores: true | ['nfl', 'soccer'] #### WebSocket event: server-heartbeat | A small {seq, ts} message every 10 seconds on every connection, independent of odds traffic, so silence is detectable. seq is counted by the server, not per connection: it goes up by 1 every 10 seconds, and after a reconnect it can jump ahead or start again from 1. Compare it only within one connection, where a gap means messages were missed. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: sent automatically after connect; narrow it with the subscribe payload #### WebSocket event: server-notice | Why the server closed your previous connection, sent once on the next connect: {code, message, at, socketId, details, hint}. Codes: BACKLOG_CAP, AGGREGATE_SHED, GHOST, SLOT_EVICTED, CLIENT_FRAME_LIMIT, SERVER_RESTART. SERVER_RESTART is sent to open connections just before a restart instead; reconnect after a short delay and send your subscription again. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: sent automatically after connect; narrow it with the subscribe payload #### WebSocket event: thunderpick-v2-update | Thunderpick Source API (v2) change notification per sport. Carries sport, count, hash and timestamp only; refetch the REST snapshot on receipt. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { thunderpick: { baseball: '*' } } | Sports: those on the "Source API book: Thunderpick (thunderpick)" block in [source-api] #### WebSocket event: tippmixpro-v2-update | TippmixPro Source API (v2) market deltas from Hungary's state sportsbook (EveryMatrix platform), pushed as they happen. Each entry is one market with its outcomes and prices, plus one {matchId}-event entry per event carrying the match record and live score info. Odds are decimal. Multi-league sports need a league filter. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { tippmixpro: { : '*' | [leagueKeys] } } | Sports: those on the "Source API book: TippmixPro (tippmixpro)" block in [source-api] #### WebSocket event: valorant-realtime | Pinnacle valorant realtime in the Pinnacle wire format (matchups, markets, prices). | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field esportsRealtime: { valorant: { league: '' } } | Sports: valorant #### WebSocket event: versus-v2-update | versus.es Source API (v2) per-market deltas at poll cadence. Every sport is multi-league, so a league filter is required. | Access: WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on. Subscribe: subscribe payload field v2: { versus: { soccer: [leagueKeys] } } | Sports: those on the "Source API book: Versus (versus)" block in [source-api] One connection carries every stream; subscribe replaces the previous subscription (the SDKs' updateSubscription merges) and player props are a separate subscription per book, acknowledged by the server. ```typescript const socket = io("https://api.owlsinsight.com", { query: { apiKey: "YOUR_API_KEY" }, transports: ["websocket"] }); socket.emit("subscribe", { sports: ["nba", "nfl"], books: ["pinnacle", "fanduel"], // narrows odds-update and the realtime events esports: true, oneXBetSoccer: true, prophetx: { sports: ["basketball"], kinds: ["game"] }, esportsRealtime: { cs2: { league: "BLAST" } }, // league required per game v2: { fanduel: { mlb: "*" }, kalshi: { nba: ["kxnbagame"] } }, // per-book v2 deltas scores: ["nba", "nfl"], // live scores -> scores-update (or scores: true) }); socket.on("odds-update", (data) => { /* data.sports[sport] = OddsEvent[] */ }); socket.emit("subscribe-props", { sports: ["nba"] }); // Pinnacle -> player-props-update, ack props-subscribed socket.emit("subscribe-fanduel-props", { sports: ["nba"] }); // -> fanduel-props-update ``` odds-update is per connection and partial: after a subscribe (or, for a connection that never subscribes, after a short grace) the first frames are a snapshot of the subscribed sports and books, split across several frames on a large board; every later frame carries only the games that changed since the last frame that connection received, so merge events by id instead of replacing your state. There is no periodic full resend: when nothing changed nothing is sent (server-heartbeat is the liveness signal), a subscribe with the same sports and books does not resend the snapshot (a different one does), and a game that leaves the board is never announced, so reload /api/v1/{sport}/odds to drop finished games. pinnacle-realtime and the v2 events are deltas, and each ps3838-realtime frame says whether it is a delta or the sport's full board, so seed from REST first. Access: the connection needs WebSocket access (Hall of Fame, or any paid tier with the add-on), and a Hall of Fame or add-on connection receives every event ([tier-features] has the REST step pinnacle-realtime needs). An event the connection has not opted into never arrives and gives no error, so a silent stream is usually a missing opt-in field or subscription, not an outage. ## [realtime-api] Real-time odds (docs: https://owlsinsight.com/docs#realtime-api) #### GET /api/v1/{sport}/ps3838-realtime | Returns real-time PS3838 odds for a sport. An independent feed from /realtime: the two are never merged, so subscribe to whichever book you trade. | Access: MVP and above | Sports: cricket, darts, handball, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis | Not available for: cs2, dota2, khl, lol, mma, table_tennis, valorant, volleyball, wnba | Query: league (optional) - Substring of the league name to filter on (case-insensitive). | Response: { success, data: [events], meta: { sport, source, odds_format, available, events, league, timestamp, feed: { state, lastDataAgeSeconds }, freshness: { ageSeconds, stale, droppedAsFinished, droppedAsStale }, ... } } | Note: Prices are American odds (meta.odds_format). | Note: meta.freshness.droppedAsFinished and droppedAsStale count events removed because they were confirmed finished or their start time is too far past to still be live. | Note: meta.available is false and meta.freshness.stale is true whenever the response is empty. | Note: Soccer women's and youth leagues: PS3838 names the clubs as in the senior league and puts Women or the age only in the league label. The team names here carry that marker (Liverpool Women, Uzbekistan U23), and so does every outcome label that names a team. | SDK: ts getPS3838Realtime() / py get_ps3838_realtime() #### GET /api/v1/{sport}/realtime | Returns real-time Pinnacle odds for a sport. For the esports keys (cs2, lol, valorant, dota2) the response is the Pinnacle wire format unchanged and league is required; every other sport returns the normalized realtime event shape. | Access: MVP and above | Sports: cricket, cs2, darts, dota2, handball, lol, mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba | Not available for: khl, mma, table_tennis, volleyball | Query: league (optional) - Substring of the league name to filter on (case-insensitive). Optional for team sports; required for the esports keys, which return 400 without it. | Response: { success, data: [events], meta: { sport, source, available, events, league, timestamp, stream, feed: { state, lastDataAgeSeconds, upstreamAgeSeconds, resubscribing, stalled }, freshness: { ageSeconds, stale, droppedAsFinished, droppedAsStale } } }; esports responses carry game instead of sport, add cacheAgeSeconds, and have no feed, stream, droppedAsFinished or droppedAsStale. | Note: meta.source names the real-time feed that answered; meta.available is false and meta.freshness.stale is true whenever the response is empty. | Note: meta.feed.lastDataAgeSeconds is the time since this sport last produced any data, or null if it never has; it is the field to watch for a feed that has stopped. For a feed that is running but hearing nothing from Pinnacle, watch meta.feed.upstreamAgeSeconds. | Note: meta.feed.upstreamAgeSeconds is the time since Pinnacle last sent anything for this sport, and meta.feed.resubscribing is true while the feed is subscribing again after a reconnect, when some leagues can be silent. lastDataAgeSeconds stays low while Pinnacle is quiet, because the board is rewritten on a schedule either way; upstreamAgeSeconds keeps growing. Both are null when unknown, which includes every response whose meta.source is not pinnacle and the moments before the feed has received anything since it started. | Note: meta.feed.stalled is true when the board has an event in play (phase live) and Pinnacle has sent nothing for this sport for longer than its normal quiet spells during play (5 minutes for NFL and college football, 8 for soccer and MLB, 12 for tennis and WNBA, 18 for NHL and every other sport): prices can be out of date even though the board was rewritten seconds ago. It is false on a board with nothing in play, however quiet, and null when upstreamAgeSeconds is null or the events carry no phase. It does not change meta.freshness.stale. The pinnacle-realtime WebSocket event carries the same field in its feed object. | Note: Each event carries status and isLive and may carry phase: scheduled (not started), live (in play, betting open), suspended (in play, betting paused for now; its markets carry suspended: true) or closed (betting over for good; the event is sent that way for a few minutes, then removed). phase can gain values, so treat an unknown one like a missing field. When phase is present, isLive is true while in-play betting on the match is running. Without phase, isLive can be false on a match that is in progress, so check status as well, and read phase first whenever it is present. | Note: A main market (moneyline, spread, total or team total) that Pinnacle takes down while the rest of the match stays open also carries suspended: true, and loses it when Pinnacle offers it again or its period ends. On a spread or total the flag is on the main line only; its alternateLines are not flagged one by one, so read each rung's last_seen for those. | Note: When meta.source is pinnacle, each event also carries lastUpdate and seenAt (epoch milliseconds) and freshness: { lastUpdate, ageSeconds, stale, refreshedAt, refreshedAgeSeconds }. freshness.lastUpdate is the price change the event's freshness is measured from and ageSeconds its age; refreshedAt is the last time the feed confirmed a price on the event, changed or not, and refreshedAgeSeconds its age. stale is true when the event reads as in play and ageSeconds is over 30 or unknown; pre-match prices can sit unchanged for hours and are not flagged. The event-level lastUpdate can be older than the newest price on an in-play event, so for the age of one market read that market's last_update. | Note: markets[].last_update is when that market's price last changed, and last_seen, where present, when the feed last confirmed it. That holds for the main lines: h2h, spreads, totals, team_totals, total_games, and their corner, first_half_ and set versions. On special_ markets folded from a sub-matchup, on btts markets and on the other markets, last_update moves with the whole matchup or sub-matchup rather than the single market, so do not read it as that market's own age. | Note: Each alternateLines rung (point and price) may carry its own last_seen, when the feed last confirmed that rung. Pinnacle does not resend every rung in every message, so a rung whose last_seen is well behind its market's is a hint that Pinnacle has taken it down, not proof. | Note: meta.stream is { epoch, seq }, the position on the pinnacle-realtime stream that this response matches, or null when the response cannot be aligned with the stream. To follow pinnacle-realtime without gaps, wait up to 25 seconds for a frame whose stream.checkpoint is true (call at once if the frames carry no stream), then call this endpoint. If meta.stream is null, replace your board with data, apply every frame, and call again after 10, 20, 40 seconds and so on, up to every 5 minutes. If its epoch differs from the checkpoint's, or its seq is lower than the checkpoint's, call again at the next checkpoint. Otherwise replace your board with data and apply only frames with a seq above meta.stream.seq, in order; a skipped seq or a new epoch after that means calling again. The pinnacle-realtime event description has the full procedure. | Note: When meta.stream is set, data is the board at that position after the league filter and the filters this endpoint applies to every response (confirmed finished events, events too old to still be live, duplicates), so an empty data array means no event matches, not a missing feed. | Note: meta.freshness.droppedAsFinished and droppedAsStale count events removed because they were confirmed finished or their start time is too far past to still be live. | Note: Esports rows each carry their own freshness; the top-level freshness is the freshest served event and stale is true only when no served event is fresh. | Note: The SDK exposes the esports keys through getEsportsRealtime / get_esports_realtime, which enforce the league parameter. | Note: Soccer women's and youth leagues: Pinnacle names the clubs as in the senior league and puts Women or the age only in the league label. The team names here carry that marker (Liverpool Women, Uzbekistan U23), and so does every outcome label that names a team, such as Draw No Bet, Double Chance (Draw Or Uzbekistan U23) and progression outrights. The v2 Pinnacle endpoints keep Pinnacle's own names. | SDK: ts getRealtime() / py get_realtime() Sub-second Pinnacle pricing in the same OddsEvent shape as /odds, Pinnacle only. /realtime is Pinnacle's own feed; when it has no events for a sport the route serves the PS3838 feed (a Pinnacle whitelabel) for that sport and meta.source names which answered, so read meta.source. /ps3838-realtime is the PS3838 feed alone. Neither ever falls back to the consensus /odds data. Esports realtime is a different shape ([esports-api]). ## [webhooks-api] Webhooks (docs: https://owlsinsight.com/docs#webhooks-api) #### GET /api/v1/webhooks | Lists your webhook endpoints with their status, filters and delivery health. Never returns signing secrets. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Response: { success, data: [endpoint], meta: { count, limit } }, where limit is how many endpoints your plan allows. | SDK: ts listWebhooks() / py list_webhooks() #### POST /api/v1/webhooks | Registers an HTTPS endpoint for signed event notifications: Pinnacle line moves, positive EV prices, game start and final, and graded player props. Returns the signing secret once. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Body: A JSON object with url (https on the default port, a public hostname), event_types (one or more of line.moved, ev.found, game.started, game.final, props.graded), an optional description (up to 200 characters) and optional filters: { sports, line_moved: { price_step_pp, point_step, markets }, ev_found: { min_ev, kinds, venues } }. Omitted filters take their defaults: every covered sport, price_step_pp 2, point_step 1, markets moneyline, spread, total, min_ev 3, kinds book, venue, every venue. | Response: 201 { success, data: { id, url, description, event_types, filters, api_version, status, status_reason, secret_hint, previous_secret_expires_at, consecutive_failures, failing_since, last_success_at, created_at, updated_at, secret } }. secret appears in this response and in rotate-secret only: store it then. | Note: Coverage by event type (a sport listed in filters.sports that one of your event types does not cover is a 400): line.moved: mlb, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, wnba; ev.found: cs2, dota2, lol, mlb, mma, nba, ncaab, ncaaf, nfl, nhl, soccer, tennis, valorant, wnba; game.started: mlb, nba, ncaab, nhl, soccer, tennis, nfl, ncaaf; game.final: mlb, nba, ncaab, nhl, soccer, tennis, nfl, ncaaf; props.graded: mlb, nfl. | Note: line.moved: Pinnacle's main moneyline, spread or total for a game that has not started moved by at least your step since the last line.moved you were sent for that market, so small moves add up. price_step_pp is the change in implied probability in percentage points (0.5, 1, 2, 3, 5), point_step the change in the spread or total line (0.5, 1, 1.5, 2, 3). A line change smaller than point_step restarts the price comparison at the new line. After a line.moved the market is quiet for 60 seconds, and a move inside that window is sent once at its end if it still clears your step. data: { sport, league, event_id, source_event_id, home_team, away_team, commence_time, book, market, reason, previous, current, max_probability_change_pp, point_change, thresholds }, where previous and current map each side (home, away, draw, over, under) to { price, point }. | Note: ev.found: a price on the EV board became tradeable at or above your min_ev, in percent (1, 2, 3, 5, 8), by the same fair-value math as the EV endpoint. Tradeable means it clears the trade bar: a sportsbook price needs an EV of at least 2%; an exchange or prediction-market price needs at least 4% on its cost, a bid, a spread of 4 cents or less and $50 at the ask, so a min_ev below those floors adds nothing. It fires when the price crosses min_ev, again only after its EV has fallen more than 1 point below min_ev, or the price has been gone or stopped clearing the trade bar for two consecutive reads (about 40 seconds), and at most once per 15 minutes for one event, venue and side. After an alert, the same price for that event, venue and side is not sent again for 2 hours unless its EV has risen by at least 1 point. Nothing fires while the odds behind the board are out of date. kinds narrows to book (sportsbook) or venue (exchange and prediction market) prices; venues narrows to the listed keys, which must be among fanduel, draftkings, betmgm, bet365, caesars, kalshi, novig, 1xbet, betonline, betr, bovada (other keys can never produce an event, so they are refused). data: { sport, league, event_id, odds_event_id, home_team, away_team, commence_time, min_ev, signal: { kind, venue, side, team, price, implied_probability, fair_probability, fair_low, ev_percent, ev, fee, route, size, link, quote_age_ms }, board_age_ms }. | Note: game.started: sent once per game when the live scores show it in play on two reads at least 15 seconds apart. game.final: sent once per game when they show it final on two reads at least 60 seconds apart and the final is believable: in tennis a player has won 2 sets, so a match that ends by retirement or walkover before that is not announced; in the other sports the game has run at least 80 minutes (soccer), 90 minutes (nba), 80 minutes (ncaab), 90 minutes (nhl), 90 minutes (mlb), 120 minutes (nfl), 150 minutes (ncaaf) from its start. A game reported final earlier than that, as an interrupted one can be, is not announced then; if the live scores show it in play again, its final is sent when it ends. A postponed or cancelled game never sends it. Status changes can trail the real moment by a few minutes. data: { sport, event_id, league, home_team, away_team, start_time, status: { state, detail }, score: { home, away } }; for tennis the score is sets won. | Note: props.graded: sent when the first player prop results of a game are confirmed (complete: false) and again when every result of the game is confirmed (complete: true); a game confirmed in one pass sends a single event with complete: true. Games graded before you subscribed are not replayed, except that a game graded in the 30 minutes before can be, and a game graded during an interruption of the service longer than 30 minutes may not be announced. data: { sport, event_id, game_date, complete, confirmed_count, pending_count, results_path }; read the results from results_path. | Note: Every delivery is a POST of the JSON { id, type, api_version, created, occurred_at, expires_at, endpoint_id, data } (api_version 2026-10-01) with the headers Owls-Webhook-Id (the event id), Owls-Event-Type, Owls-Delivery-Attempt and Owls-Signature. | Note: Owls-Signature is t=,v1=. Compute HMAC-SHA256 with your signing secret (the whole string, whsec_ included, as UTF-8) over t, a period, and the raw request body, then compare it with v1 in constant time, and reject a t more than 5 minutes from your clock. The SDKs ship verifyWebhookSignature (TypeScript) and verify_webhook_signature (Python). | Note: Delivery is at least once and unordered: dedupe on id, which is identical on every retry, and do not rely on one event arriving before another. | Note: Answer with any 2xx within 10 seconds (3 to connect). Anything else is a failure, a redirect included: redirects are never followed. line.moved and ev.found retry after about 5 s, 20 s, 1 m and then expire; the other types retry after about 10 s, 1 m, 5 m, 15 m, 1 h, 3 h, 6 h, 12 h. Retry-After is honoured. Nothing is sent after expires_at: line.moved 5 minutes, ev.found 2 minutes, game.started 2 hours, game.final 24 hours, props.graded 24 hours after the event. | Note: 410 Gone disables the endpoint. After 5 failures in a row its deliveries pause briefly (30 seconds, doubling up to 30 minutes). After 50 failures in a row and 72 hours without a success it is disabled (status auto_disabled); PATCH { "enabled": true } resumes it. | Note: The url must be https on the default port with a public hostname. IP addresses, private or reserved addresses and internal hostnames are refused, at registration and on every delivery. | Note: Endpoints per plan: MVP 3, Hall of Fame 10. Events per account: MVP 120 a minute and 20,000 a day, Hall of Fame 600 a minute and 100,000 a day; events over the cap are dropped, and so are new line.moved and ev.found events for an endpoint with more than 1,000 undelivered. At most 30 endpoint changes per minute per account. | SDK: ts createWebhook() / py create_webhook() #### DELETE /api/v1/webhooks/{id} | Deletes a webhook endpoint. Undelivered events for it are cancelled and nothing more is sent. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Response: { success, data: { id, deleted: true } } | SDK: ts deleteWebhook() / py delete_webhook() #### GET /api/v1/webhooks/{id} | Returns one webhook endpoint: url, event types, filters, status and delivery health (consecutive_failures, failing_since, last_success_at). | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Response: { success, data: endpoint }. 404 for an id that is not one of your endpoints. | SDK: ts getWebhook() / py get_webhook() #### PATCH /api/v1/webhooks/{id} | Changes a webhook endpoint: url, event_types, description, filters, or enabled to turn deliveries off and on. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Body: A JSON object with any of url, event_types, description, filters and enabled (boolean). event_types replaces the list. filters merges into the stored filters: a section you send (line_moved, ev_found) changes only the fields you send, and sports replaces the list. | Response: { success, data: endpoint } | Note: enabled: false stops deliveries and cancels the undelivered ones. enabled: true resumes an endpoint that you disabled or that was disabled after failing (status auto_disabled), and resets its failure count. | Note: The same url and coverage checks as create apply. | SDK: ts updateWebhook() / py update_webhook() #### GET /api/v1/webhooks/{id}/deliveries | Lists recent deliveries to one endpoint, newest first: status, attempts, the last response code and latency, and the exact payload sent. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Query: status (optional) - Only deliveries in this status. [values: pending, delivered, failed, expired, cancelled, shadow] | Query: type (optional) - Only this event type. [values: line.moved, ev.found, game.started, game.final, props.graded, webhook.test] | Query: limit (optional) - Page size, 1 to 50 (default 20). | Query: starting_after (optional) - An event id (evt_...) from the previous page: returns the deliveries older than it. | Response: { success, data: [{ id, type, status, attempts, next_attempt_at, expires_at, last_status_code, last_error, last_latency_ms, response_excerpt, occurred_at, created_at, delivered_at, payload }], has_more } | Note: status is pending (queued or between retries), delivered, failed (retries exhausted), expired (past expires_at), cancelled (the endpoint was disabled or deleted) or shadow (recorded while the event type was held back, never sent). Deliveries are kept for 14 days. | Note: response_excerpt is up to the first 512 bytes your receiver answered with. | SDK: ts listWebhookDeliveries() / py list_webhook_deliveries() #### POST /api/v1/webhooks/{id}/rotate-secret | Issues a new signing secret for the endpoint and returns it once. The previous secret keeps signing alongside it until it expires. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Body: Optional JSON { expire_previous_after_hours }: how long the previous secret keeps signing, 0 to 168 (default 24). 0 retires it at once. | Response: { success, data: { id, secret, secret_hint, previous_secret_expires_at } } | Note: While the previous secret is live, Owls-Signature carries two v1 values, one per secret. Accept either, then switch your receiver to the new secret. | SDK: ts rotateWebhookSecret() / py rotate_webhook_secret() #### POST /api/v1/webhooks/{id}/test | Queues a webhook.test event to the endpoint, signed like every other event, so you can check your receiver and its signature check. | Access: MVP and above | Status: beta (shape is stable, signals still evolving) | Response: 202 { success, data: { id, type: "webhook.test", status: "pending" } }. Follow it with the deliveries endpoint. | Note: One test event per endpoint every 10 seconds (429 with Retry-After otherwise). The endpoint must be enabled (409 otherwise). | Note: data is { message }. A test event retries like game.final and expires after 1 hour. | SDK: ts testWebhook() / py test_webhook() Webhooks push signed JSON events to an HTTPS URL the customer registers: Pinnacle line moves (line.moved), positive EV prices (ev.found), game start and final (game.started, game.final) and graded player props (props.graded). Beta, MVP and Hall of Fame only (MVP 3 endpoints, Hall of Fame 10); other plans get 403 with upgradeUrl. Endpoints belong to the account, so any of its API keys can manage them, and each management request counts against the plan's rate limit and monthly quota ([rate-limits]). The 201 create response carries secret, the endpoint's signing secret (whsec_...), shown only there and in the rotate-secret response; later reads show secret_hint, its last 4 characters. event_types come back sorted and every filter field filled in. Register, pause with an update, delete, send a test event, rotate the secret and read the deliveries log: ```bash curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/owls-webhooks", "event_types": ["line.moved", "ev.found"], "filters": { "sports": ["nba"], "line_moved": { "price_step_pp": 1 } } }' \ "https://api.owlsinsight.com/api/v1/webhooks" curl -X PATCH -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' \ "https://api.owlsinsight.com/api/v1/webhooks/whk_3q2mxf6kd7bq4yazvtj5hwle" curl -X DELETE -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.owlsinsight.com/api/v1/webhooks/whk_3q2mxf6kd7bq4yazvtj5hwle" curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.owlsinsight.com/api/v1/webhooks/whk_3q2mxf6kd7bq4yazvtj5hwle/test" curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "expire_previous_after_hours": 24 }' \ "https://api.owlsinsight.com/api/v1/webhooks/whk_3q2mxf6kd7bq4yazvtj5hwle/rotate-secret" curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.owlsinsight.com/api/v1/webhooks/whk_3q2mxf6kd7bq4yazvtj5hwle/deliveries?status=failed&limit=10" ``` Ids in the payloads: line.moved event_id is our game id, {sport}:{away}@{home}-{date}, as used by /odds and the history endpoints, and source_event_id is Pinnacle's id; ev.found event_id is canonicalEventId on /ev and odds_event_id is eventId on /ev; game.started and game.final event_id is the game's id on /api/v1/{sport}/scores/live; props.graded event_id is the game_id that /api/v1/{sport}/props/results takes. line.moved compares each market with its line at the last line.moved (or when first seen), counts only games that have not started, and ignores alternate and suspended lines. ev.found does not send a price that already qualifies when the endpoint is created, only the crossings that come after, and only for games that have not started. For props.graded, games graded before you subscribed are not replayed, except that a game graded in the 30 minutes before can be. A delivery in status shadow was recorded while its event type was held back and never sent. A test event's data.message is "Test event from Owls Insight. Verify the Owls-Signature header with your signing secret." Deliveries also carry User-Agent OwlsInsight-Webhooks/1.0 and Owls-Delivery-Attempt (1 on the first attempt, 2 on the first retry). Up to 64 KB of the receiver's answer is read and its first 512 bytes kept as response_excerpt. At most 4 requests are in flight to one endpoint and 8 to one hostname. Retry-After is honoured up to 1 minute for line.moved and ev.found and 12 hours for the other types. Each event sent to one endpoint counts once against the plan caps, the day cap runs per UTC day, and an event over a cap is dropped and never appears in the deliveries log. When a plan stops including webhooks, or a trial or past-due period ends, deliveries stop within about a minute; after a move from Hall of Fame to MVP the 3 oldest enabled endpoints keep receiving events. To rotate without a gap: rotate and store the new secret, deploy it to the receiver (while both secrets sign, a receiver holding either one verifies), then let the previous one expire at previous_secret_expires_at. Errors are JSON { success: false, error, code, message } with code: invalid_body, sport_not_covered (problems lists each uncovered pair), filter_never_matches, event_type_unavailable (an event type being held back; unavailable lists it), invalid_query and the url codes https_required, port_not_allowed, credentials_not_allowed, ip_literal_not_allowed, hostname_not_allowed, url_too_long, invalid_url, url_not_allowed (resolves to a private or reserved address) and url_unresolvable, all 400; not_found (404, also for another account's id); endpoint_limit (409, with limit; disabled endpoints count too); endpoint_disabled (409, a test event for an endpoint that is not enabled); rate_limited (429, with Retry-After); unavailable (503). The plan's rate limits (429) and the API key check (401, and 403 with upgradeUrl on a plan without webhooks) use the general API error shape, with no code ([rate-limits]). Verify every delivery against the raw request body before parsing it. verifyWebhookSignature is in owls-insight-ts 0.73.0 or later and verify_webhook_signature in owls-insight 0.32.0 or later, each taking the raw body, the Owls-Signature header, the secret and an optional tolerance in seconds (default 300); the management methods named on the endpoint blocks above are in the same versions: ```typescript import express from "express"; import { verifyWebhookSignature } from "owls-insight-ts"; // owls-insight-ts 0.73.0 or later const app = express(); const seen = new Set(); // express.raw keeps the body as the exact bytes that were signed. app.post("/owls-webhooks", express.raw({ type: "application/json" }), (req, res) => { const signature = req.header("Owls-Signature") ?? ""; if (!verifyWebhookSignature(req.body, signature, process.env.OWLS_WEBHOOK_SECRET!)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); if (!seen.has(event.id)) { // delivery is at least once: dedupe on id seen.add(event.id); console.log(event.type, event.data); } res.sendStatus(200); // any 2xx within 10 seconds }); ``` ```python import json import os from flask import Flask, abort, request from owls_insight import verify_webhook_signature # owls-insight 0.32.0 or later app = Flask(__name__) SECRET = os.environ["OWLS_WEBHOOK_SECRET"] seen = set() @app.post("/owls-webhooks") def owls_webhooks(): raw = request.get_data() # the exact bytes that were signed if not verify_webhook_signature(raw, request.headers.get("Owls-Signature", ""), SECRET): abort(400) event = json.loads(raw) if event["id"] not in seen: # delivery is at least once: dedupe on id seen.add(event["id"]) print(event["type"], event["data"]) return "", 200 # any 2xx within 10 seconds ``` Without an SDK, Node.js: ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody: the request body exactly as received (a Buffer or a string), before any JSON parsing. export function verifyOwlsSignature(rawBody, header, secret, toleranceSec = 300) { let timestamp = null; const signatures = []; for (const part of String(header ?? "").split(",")) { const i = part.indexOf("="); if (i <= 0) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t" && /^\d{1,12}$/.test(value)) timestamp = Number(value); if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(Buffer.from(value, "hex")); } if (timestamp === null || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); return signatures.some((signature) => timingSafeEqual(signature, expected)); } ``` Python, standard library only: ```python import hashlib import hmac import re import time def verify_owls_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool: """raw_body: the request body exactly as received, before any JSON parsing.""" timestamp = None signatures = [] for part in (header or "").split(","): key, _, value = part.partition("=") key, value = key.strip(), value.strip() if key == "t" and re.fullmatch(r"[0-9]{1,12}", value): timestamp = int(value) elif key == "v1" and re.fullmatch(r"[0-9a-fA-F]{64}", value): signatures.append(value.lower()) if timestamp is None or not signatures: return False if abs(time.time() - timestamp) > tolerance_sec: return False signed = f"{timestamp}.".encode("ascii") + raw_body expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(signature, expected) for signature in signatures) ``` Known answer for checking an implementation (with the 5-minute check off): secret whsec_b3dscy1pbnNpZ2h0IHdlYmhvb2sgc2lnbmluZyB2ZWM, t 1767225600 and the body {"id":"evt_aaaaaaaaaaaaaaaaaaaaaaaaaa","type":"webhook.test","api_version":"2026-10-01","data":{"message":"café ✓"}} give v1 f5b35d107193da943904fce8d56e1b5475ce20dcb421dcacf0cd0a200db40f35. ## [rate-limits] Rate limits (docs: https://owlsinsight.com/docs#rate-limits) | Bench (bench, $9.99/mo or $95.90/yr billed yearly): 10,000 requests/month; 20 requests/minute; 1 concurrent request; no history routes; WebSocket: Add-on ($99.99/mo or $959.90/yr) | Rookie (rookie, $24.99/mo or $239.90/yr billed yearly): 75,000 requests/month; 120 requests/minute; 5 concurrent requests; no history routes; WebSocket: Add-on ($99.99/mo or $959.90/yr) | MVP (mvp, $49.99/mo or $479.90/yr billed yearly): 300,000 requests/month; 400 requests/minute; 15 concurrent requests; 3 in flight on /api/v1/history/* routes; WebSocket: Add-on ($99.99/mo or $959.90/yr) | Hall of Fame (hall_of_fame, $200/mo or $1,920/yr billed yearly): unlimited requests/month; 1000 requests/minute; 20 concurrent requests; 4 in flight on /api/v1/history/* routes; WebSocket: 1 connection Quotas are per key and the monthly quota resets on the billing date, not the first of the month. Every REST response carries X-RateLimit-Remaining-Minute, X-RateLimit-Remaining-Month ("unlimited" on Hall of Fame), X-RateLimit-Reset-Minute (unix ms) and X-RateLimit-Reset-Month (ISO or "unlimited"); a 429 adds Retry-After in seconds (retryAfterMs / retry_after_ms in the SDKs). Requests beyond the concurrent limit get 429 immediately, not queued. History routes (/api/v1/history/odds, /api/v1/history/props and the rest of that family) add a smaller per-key in-flight cap (MVP 3, Hall of Fame 4, the tier lines say which): over it is 429 with Retry-After: 2 and code HISTORY_CONCURRENCY, wait and resend; a 503 with Retry-After there means that one request was too expensive, so narrow it (book, market, side, time range) or page with a smaller limit. Page one event at a time (limit/offset, stop on a short page; iterHistoryOdds / iter_history_odds do this and retry both) and never fan one event out across books in parallel. WebSocket messages do not count against the REST quota. ## [tier-features] Tier features (docs: https://owlsinsight.com/docs#tier-features) | REST access labels (every endpoint block states its own): | All paying tiers (bench, rookie, mvp, hall_of_fame): 25 endpoints | Rookie and above (rookie, mvp, hall_of_fame): 15 endpoints | MVP and above (mvp, hall_of_fame): 31 endpoints | WebSocket events by access: | WebSocket access, plus MVP or above for the REST board: pinnacle-realtime, pinnacle-realtime-sync, ps3838-realtime | WebSocket access: Hall of Fame, or any paid tier with the WebSocket add-on: 1xbet-update, 4casters-v2-update, ballybet-v2-update, bet105-v2-update, bet365-v2-update, betmgm-props-update, betonline-realtime, betrivers-v2-update, betus-v2-update, caesars-props-update, cs2-realtime, dota2-realtime, draftkings-props-update, draftkings-v2-update, esports-update, fanaticsmarkets-books-v2-update, fanaticsmarkets-v2-update, fanduel-props-update, fanduel-v2-update, kalshi-v2-update, lol-realtime, lowvig-v2-update, mybookie-v2-update, odds-update, pinnacle-v2-update, player-props-update, plays-update, polymarket-v2-update, propbuilder-v2-update, prophetx-update, scores-update, server-heartbeat, server-notice, thunderpick-v2-update, tippmixpro-v2-update, valorant-realtime, versus-v2-update Every paying tier gets consensus odds, events, scores, schedule, results, normalization, ProphetX, 1xBet soccer and the whole Source API over REST; props, SGP, splits, stats and props history start at Rookie; real-time Pinnacle, EV, historical data, SGP comparisons, batches, quote history and Kalshi combos (/sgp/compare, /sgp/batch, /sgp/history, /sgp/combo-compare) and the SGP fair price (fair: true) start at MVP. WebSocket access is a separate entitlement: Hall of Fame includes one connection, every other paid tier needs the add-on, and MVP alone does NOT include it. A Hall of Fame or add-on connection receives every WebSocket event, props and the esports realtime events included, whatever its plan. The Pinnacle and PS3838 realtime events reach it too: ps3838-realtime snapshots carry each sport's full board, but pinnacle-realtime frames are always partial, so that board needs a REST load from /realtime, which is MVP and above. Free keys have a 0 requests/minute limit, so every call is 429 until a plan is active. ## [coverage] Sportsbooks and coverage (docs: https://owlsinsight.com/docs#coverage) | Books (key: label; kind; in v1 /odds; has v1 props; has a v2 route): | 1xbet: 1xBet; sportsbook; v1 odds yes; v1 props no; v2 no | 4casters: 4casters; exchange; v1 odds no; v1 props no; v2 yes | 888sport: 888sport; sportsbook; v1 odds no; v1 props no; v2 yes | ballybet: Bally Bet; sportsbook; v1 odds no; v1 props no; v2 yes | bet105: Bet105; sportsbook; v1 odds no; v1 props no; v2 yes | bet365: Bet365; sportsbook; v1 odds no; v1 props no; v2 yes | betmgm: BetMGM; sportsbook; v1 odds yes; v1 props yes; v2 no | betonline: BetOnline; sportsbook; v1 odds no; v1 props no; v2 yes | betr: Betr; sportsbook; v1 odds yes; v1 props no; v2 no | betrivers: BetRivers; sportsbook; v1 odds no; v1 props no; v2 yes | betus: BetUS; sportsbook; v1 odds no; v1 props no; v2 yes | bookmaker: Bookmaker.eu; sportsbook; v1 odds no; v1 props no; v2 yes | bovada: Bovada; sportsbook; v1 odds yes; v1 props no; v2 yes | caesars: Caesars; sportsbook; v1 odds yes; v1 props yes; v2 no | circa: Circa Sports; sportsbook; v1 odds yes; v1 props no; v2 no | draftkings: DraftKings; sportsbook; v1 odds yes; v1 props yes; v2 yes | fanatics: Fanatics Sportsbook; sportsbook; v1 odds no; v1 props no; v2 yes | fanaticsmarkets: Fanatics Markets; prediction market; v1 odds no; v1 props no; v2 yes | fanduel: FanDuel; sportsbook; v1 odds yes; v1 props yes; v2 yes | hardrock: Hard Rock Bet; sportsbook; v1 odds yes; v1 props no; v2 yes | kalshi: Kalshi; prediction market; v1 odds no; v1 props yes; v2 yes | lowvig: LowVig; sportsbook; v1 odds no; v1 props no; v2 yes | mybookie: MyBookie; sportsbook; v1 odds no; v1 props no; v2 yes | novig: Novig; exchange; v1 odds yes; v1 props yes; v2 yes | pick6: DraftKings Pick6; pick'em; v1 odds no; v1 props no; v2 yes | pinnacle: Pinnacle; sportsbook; v1 odds yes; v1 props yes; v2 yes | polymarket: Polymarket; prediction market; v1 odds no; v1 props no; v2 yes | polymarketus: Polymarket US; prediction market; v1 odds no; v1 props no; v2 yes | propbuilder: Prop Builder; sportsbook; v1 odds no; v1 props no; v2 yes | prophetx: ProphetX; exchange; v1 odds no; v1 props no; v2 no | south_point: South Point; sportsbook; v1 odds yes; v1 props no; v2 no | stake: Stake; sportsbook; v1 odds no; v1 props no; v2 yes | stations: Station Casinos; sportsbook; v1 odds yes; v1 props no; v2 no | thescore: theScore; sportsbook; v1 odds no; v1 props no; v2 yes | thunderpick: Thunderpick; sportsbook; v1 odds no; v1 props no; v2 yes | tippmixpro: TippmixPro; sportsbook; v1 odds no; v1 props no; v2 yes | underdog: Underdog; pick'em; v1 odds no; v1 props no; v2 yes | versus: Versus; sportsbook; v1 odds no; v1 props no; v2 yes | westgate: Westgate; sportsbook; v1 odds yes; v1 props no; v2 no | wynn: Wynn; sportsbook; v1 odds yes; v1 props no; v2 no | v1 sport keys: cricket (Cricket), cs2 (Counter-Strike 2), darts (Darts), dota2 (Dota 2), handball (Handball), khl (KHL (Kontinental Hockey League)), lol (League of Legends), mlb (MLB), mma (MMA), nba (NBA), ncaab (NCAA basketball), ncaaf (NCAA football), nfl (NFL), nhl (NHL), soccer (Soccer), table_tennis (Table tennis), tennis (Tennis), valorant (Valorant), volleyball (Volleyball), wnba (WNBA) | Book-scoped sport keys (valid only on that book's v2 routes, never on v1): | 4casters: cfl, esports | ballybet: aussie-rules, boxing, cycling, formula1, golf, motorsport, rugby-league, rugby-union, snooker, specials, surfing, table-tennis | bet105: alpine-skiing, archery, athletics, aussie-rules, badminton, bandy, baseball, basketball, basketball-3x3, biathlon, bowls, boxing, cfl, chess, curling, cycling, esports, fencing, field-hockey, floorball, football, formula1, futsal, gaelic-sports, golf, greyhounds, hockey, horse-racing, hurling, judo, kabaddi, kbo, lacrosse, motorbikes, motorsport, nascar, netball, npb, olympics, padel, pool, rugby-league, rugby-union, sailing, ski-jumping, snooker, softball, speedway, squash, sumo, supercars, surfing, table-tennis, taekwondo, triathlon, trotting, ultimate, water-polo | betrivers: aussie-rules, boxing, cycling, formula1, golf, motorsport, rugby-league, rugby-union, snooker, specials, surfing, table-tennis | betus: alpine-skiing, archery, athletics, aussie-rules, badminton, bandy, baseball, basketball, basketball-3x3, biathlon, bowls, boxing, cfl, chess, curling, cycling, esports, fencing, field-hockey, floorball, football, formula1, futsal, gaelic-sports, golf, greyhounds, hockey, horse-racing, hurling, judo, kabaddi, kbo, lacrosse, motorbikes, motorsport, nascar, netball, npb, olympics, padel, pool, rugby-league, rugby-union, sailing, ski-jumping, snooker, softball, speedway, squash, sumo, supercars, surfing, table-tennis, taekwondo, triathlon, trotting, ultimate, water-polo | bookmaker: alternative-sports, aussie-rules, baseball, basketball, boxing, e-gaming, entertainment, esports, football, futsal, futures, golf, ice-hockey, motorsport, politics, rugby, surfing, table-tennis | fanaticsmarkets: boxing, golf | hardrock: AMERICAN_FOOTBALL, AUSSIE_RULES, BASEBALL, BASKETBALL, BOXING, CRICKET, DARTS, HANDBALL, ICE_HOCKEY, MMA, RUGBY, SNOOKER, SOCCER, TABLE_TENNIS, TENNIS, VOLLEYBALL | kalshi: overwatch, pubg, rainbow6, rocketleague | lowvig: boxing, golf, table-tennis | pick6: nascar, pga | pinnacle: esports | polymarket: overwatch, pubg, rainbow6, rocketleague | polymarketus: boxing, golf, overwatch, rainbow6, rocketleague | stake: baseball, basketball, ice-hockey, table-tennis | thunderpick: american-football, baseball, basketball | tippmixpro: boxing | underdog: kbo, npb, pga | versus: afl, badminton, basketball, boxing, esports, golf, padel, table-tennis The books table answers "which book is where": v1 odds = inside /api/v1/{sport}/odds (reach it with ?books=), v1 props = /api/v1/{sport}/props, v2 = a Source API route; a book can be in only one. Book-scoped sport keys are valid only on that book's v2 route. A sport absent from an endpoint's Sports line is not served there; say so rather than guessing. ## [errors] Errors (docs: https://owlsinsight.com/docs#errors) 400 invalid or missing parameter (unknown sport, a books filter with no valid value, a multi-league v2 call without ?league=, a malformed SGP leg, an SGP book the builder does not price: code SGP_BOOK_UNSUPPORTED with supportedBooks, a supported SGP book sent a sport it does not price: code SGP_BOOK_SPORT_UNSUPPORTED with sports), { error, message? } names it; 401 missing or invalid key; 403 tier lacks the feature, with upgradeUrl, currentTier, requiredTier; 404 unknown route or resource; 410 retired endpoint (the RETIRED blocks name the replacement); 422 valid request that cannot be priced as sent (the SGP builder; every 422 body carries reasonCode, also sent as code: "unresolved" with a reason per leg when a leg did not resolve, otherwise a refused parlay with failureCodes, legIndex and lineEchoed where they apply; fix the legs or change the slip, never resend the same body unchanged); 429 rate limit, concurrency limit, the per-key history in-flight cap (code HISTORY_CONCURRENCY) or the per-key SGP build limit (code SGP_RATE_LIMIT with limit and resetAt), honour Retry-After; 500 retry with backoff; 502 is not used by the SGP builder; 503 cold cache ("Scores not yet available"), a rate-limit system outage, an SGP build that produced no price (reasonCode no-price with Retry-After: 2, retry later) or refused for now (code SGP_BUSY or SGP_RATE_LIMIT_UNAVAILABLE with Retry-After: 2, SGP_BOOK_DISABLED with Retry-After: 60 when that book's pricing is switched off or paused, or no code for a temporary outage on our side), or a history read too expensive for one request (Retry-After present, ServiceBusyError in the SDKs: narrow with book/market/side/time range or page smaller before retrying); the cold cache, the outage and the SGP 503s are worth retrying after Retry-After or a short backoff, and a history 503 only once narrowed. A v2 response with success true and meta.status "no-data" or "stale" is not an error. ## [go-sdk] Go SDK (docs: https://owlsinsight.com/docs#go-sdk) Module github.com/owlsinsight/owls-insight-sdk-go (Go 1.23+, one dependency), installed with go get github.com/owlsinsight/owls-insight-sdk-go@latest and imported as owls. Source https://github.com/owlsinsight/owls-insight-sdk-go, reference https://pkg.go.dev/github.com/owlsinsight/owls-insight-sdk-go. Server-side programs only: an API key is a credential. Method names are the TypeScript names with the first letter upper-cased (getOdds is GetOdds, getHistoryOdds is GetHistoryOdds); every method takes a context.Context first, and calls with many filters take a params struct (a nil pointer sends none). Every v2 book goes through GetV2(ctx, book, sport, league) and GetV2Leagues(ctx, book, sport), with the book's own JSON in Data as *json.RawMessage. ```go c, err := owls.NewClient(os.Getenv("OWLS_INSIGHT_API_KEY")) odds, err := c.GetOdds(ctx, "nba", &owls.OddsParams{Books: []string{"pinnacle", "fanduel"}}) // odds.Data keyed by book s := c.Stream(owls.WithSubscription(owls.Subscription{Sports: []string{"nba"}, Books: []string{"pinnacle"}})) s.OnOddsUpdate(func(u *owls.OddsUpdatePayload, err error) { /* partial after the first snapshot: merge by event id */ }) err = s.Run(ctx) // blocks; reconnects and resends the subscription by itself ``` A non-2xx response is *owls.APIError (Status, Code, Message, RetryAfter); match it with errors.Is(err, owls.ErrRateLimited), ErrUnauthorized, ErrForbidden, ErrNotFound or ErrServiceBusy. Nothing is retried unless the client is built with owls.WithRetry(owls.RetryPolicy{MaxRetries: 3}). IterHistoryOdds and IterHistoryProps page through one event. ## [kotlin-sdk] Kotlin/JVM SDK (docs: https://owlsinsight.com/docs#kotlin-sdk) Maven Central artifact com.owlsinsight:owls-insight (https://central.sonatype.com/artifact/com.owlsinsight/owls-insight), package com.owlsinsight, for Kotlin and Java: Java 11+, and Kotlin 2.4+ for Kotlin callers. Gradle: implementation("com.owlsinsight:owls-insight:0.9.0"); Maven: groupId com.owlsinsight, artifactId owls-insight. Method names are the TypeScript SDK's (getOdds, getHistoryOdds, buildSgp); filter-heavy endpoints take a params class (named arguments in Kotlin, builder() in Java). Every REST method returns an OwlsCall: await() in a coroutine, execute() to block, future() for a CompletableFuture. Every v2 book goes through getV2(book, sport, league) and getV2Leagues(book, sport), with the book's JSON in data as JsonElement. BetMGM props use getBookPropsBetmgm(sport); getBookProps refuses betmgm. ```kotlin OwlsInsight("owlsinsight_...").use { client -> val odds = client.rest.getOdds("nba", OddsParams(books = listOf("pinnacle", "fanduel"))).await() // data keyed by book client.ws.on(WsEvents.ODDS_UPDATE) { update -> println(update.sports?.keys) } client.ws.connect(SubscribeOptions(sports = listOf("nba"), books = listOf("pinnacle"))).await() // resent on every reconnect } ``` Java: client.getRest().getOdds("nba", OddsParams.builder().books("pinnacle", "fanduel").build()).execute(). Errors: AuthenticationException (401), ForbiddenException (403), NotFoundException (404), RateLimitException (429, retryAfterMs), ServiceBusyException (503), any other status OwlsInsightException. Retries are off unless RetryOptions(maxRetries = 2). ## [mcp] Use Owls Insight from AI agents (MCP) (docs: https://owlsinsight.com/docs#mcp) A hosted MCP (Model Context Protocol) server lets AI assistants and agents (Claude Code, Claude Desktop, Cursor and other MCP clients) query live odds and the rest of the API with the account's API key. Endpoint https://api.owlsinsight.com/mcp, Streamable HTTP transport, the key in the Authorization: Bearer YOUR_API_KEY header. Any paid plan can connect; each tool is available on the same plans as the endpoint it reads. The server is in beta: if it says a key is not enabled yet, contact support@owlsinsight.com. Claude Code: ```bash claude mcp add --transport http owls-insight https://api.owlsinsight.com/mcp --header "Authorization: Bearer YOUR_API_KEY" ``` Cursor, in ~/.cursor/mcp.json or the project's .cursor/mcp.json: ```json { "mcpServers": { "owls-insight": { "url": "https://api.owlsinsight.com/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Claude Desktop, in claude_desktop_config.json, through the mcp-remote bridge (needs Node.js). The header is passed through an environment variable with no space after the colon, which avoids an argument-splitting problem in some clients: ```json { "mcpServers": { "owls-insight": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.owlsinsight.com/mcp", "--header", "Authorization:${OWLS_AUTH}"], "env": { "OWLS_AUTH": "Bearer YOUR_API_KEY" } } } } ``` Tools, with the plans that can call each one: - find_events: a sport's games with event ids, teams, start times and status. All paid plans. - get_schedule: games by day, finished ones included, from about three days back to nine days ahead (start time, status, score and the event id of a game on the odds board), or one sport's upcoming games or recent results with final scores. All paid plans. - get_odds: every sportsbook's moneyline, spread and total for one game. All paid plans. - get_best_prices: the best available price for each side of each game, across books. All paid plans. - get_fair_value_and_ev: fair value and positive expected value prices, on the moneyline, spreads or totals. MVP and Hall of Fame. - get_fair_line: the fair (no-vig) moneyline, spread and total of a sport's upcoming games, with each game's event id. MVP and Hall of Fame. - get_player_props: player prop lines and prices across books, for one game or one player. Rookie and above. - get_props_fair_value: the fair (no-vig) over and under of upcoming player props and the sportsbook prices that beat them (positive expected value), for a sport or one game. MVP and Hall of Fame. - price_same_game_parlay: a sportsbook's own same game parlay price for 2 to 12 legs. Rookie and above. - get_betting_splits: bet and money percentages per game. Rookie and above. - get_live_scores: live scores for a sport: score, period and status (the game clock for NBA, NHL, NFL and NCAAF), plus games about to start or just finished. All paid plans. - find_past_games: finished games by sport, team and date range (up to 31 days per call), with event ids, start times and final scores. MVP and Hall of Fame. - get_odds_history: odds snapshots over time for one finished game: each book's moneyline, spread and total, both sides, with the time each was captured. MVP and Hall of Fame. - get_closing_line: closing lines for one finished game, each with its source label and last update. MVP and Hall of Fame. - get_prop_history: one player's prop line and over and under prices over time, for one finished game. MVP and Hall of Fame. - get_prop_results: graded player prop results for a game or a day. Rookie and above. - get_prop_trends: one player's recent games in one prop category: each game's actual stat and, against your line or a book's closing line, the hit rate and current streak. Rookie and above. - get_book_markets: one book's raw markets for a sport from the Source API (v2), with prices in the book's own format. All paid plans. find_past_games, get_odds_history, get_closing_line and get_prop_history read the historical data ([history-api]). Find a finished game's event id with find_past_games, then pass it to the others. Dates are UTC: the date in an event id is the UTC date of the scheduled start (MLB games from October 11, 2026 carry the US Eastern date). get_closing_line labels each closing line with its source: published-close (the book's final published price), live-derived (the last price captured before the start) or archive-1, archive-2 and archive-3 (historical archives for older MLB and NBA games). get_prop_history needs the player's name and the prop type, such as passing_yards. get_schedule lists the games of 1 to 10 days in the time zone given (America/New_York by default), finished ones included, from about three days back to nine days ahead; for an older finished game use find_past_games. Its upcoming and results views list one sport's upcoming games, or its recently finished games with final scores. get_prop_trends takes the player's full name and a prop category, such as passing_yards: pass a line to grade each game against that number, or a book to grade each game against the main line that book closed at for it. get_props_fair_value scores props of games that have not started, at half lines such as 1.5: the fair comes from Pinnacle's over and under at the same line. get_book_markets reads the Source API ([source-api]), one book at a time. Called with only a sport, it lists the books that carry that sport, which is not an API request; each call that names a book is one. With a book, it pages through that book's markets for the sport, with a preview of each entry. Where a book splits the sport into leagues, the tool lists them and league picks one; on a book that serves every league in one list, a list cut to fit names only the leagues in it. contains keeps only the entries with the words given, such as a fighter's or a team's name; on a list cut to fit, it searches only what that list holds, so pass league to search one league whole. Pass an entry's id to read it: a long entry comes back as a page of the items inside it, each with an id to read it by (a long single value comes in parts), and the tool says how to read the rest. Prices come in each book's own format, not converted to American odds: depending on the book, American odds, decimal odds (on some books times 1000), probabilities, or an index into the book's price table. Each tool call is an ordinary API request against the plan's rate limit and monthly quota ([rate-limits]); price_same_game_parlay can make two, one to find the game and one to price it, find_past_games up to three when it searches a date range for a team name, get_prop_history up to three when it looks a player's name up, and a get_book_markets call with only a sport, which lists the books that carry it, makes none. Connecting and listing tools do not count. A call the API refuses because too many of the key's requests are in flight at once is retried once, and both attempts count. Results are summarized and capped; long lists come back in pages, and the tool says how to get the next page. The key is a credential: treat it like a password and use it only in an assistant or agent that acts on the account holder's behalf, never in a shared or public workspace, a shared agent config or a public repository (that includes an assistant shared with a team or open to the public). The account holder remains responsible for everything done with the key, including through these tools (Terms of Service, sections 5 and 7). Example prompts: "What are the best moneyline prices for tonight's NBA games?", "Show every sportsbook's spread and total for the next Chiefs game.", "Compare Patrick Mahomes's passing yards props across books.", "How did the spread move before the Eagles' last game, and where did it close at each book?" ## [patterns] Common patterns ### Seed from REST, then apply v2 deltas ```typescript const seed = await client.rest.getFanDuelV2("mlb"); const state = new Map(Object.entries(seed.data)); client.ws.subscribe({ v2: { fanduel: { mlb: "*" } } }); client.ws.on("fanduel-v2-update", (msg) => state.set(msg.marketId, msg.data)); // On reconnect refetch with If-None-Match: "" (keep the quotes) and replace state only on a 200. ``` ### Detect line movement on odds-update Keep a Map of event.id to the last spreads point (event.bookmakers[0].markets.find(m => m.key === "spreads").outcomes[0].point) and log when an event arriving in a frame differs; after the first snapshot each odds-update frame holds only the games that changed, so compare each event as it arrives (an event absent from a frame is unchanged or has left the board). ### "Is sport X supported for feature Y" Read the endpoint block: Sports lists what is accepted, "Not available for" what is not; for a Source API book read its block in [source-api], for props per book the "Sports for " lines. Never infer support from the sport appearing elsewhere.