TheRundown
  1. Home
  2. Blog
  3. Developer guide

Developer guide

Sports odds WebSocket API vs. REST polling

When REST polling is enough and when you need a sports odds WebSocket API: delays by plan, measured data-point costs, and a Python streaming client.

· 14 min read

TheRundown’s odds API delivers prices two ways, and the right one depends on how soon you need to know a price changed. REST polling works on every plan, with prices delayed by the plan: five minutes on Free, 60 seconds on Starter, and 30 seconds on Pro. TheRundown’s sports odds WebSocket API starts on Ultra ($399 a month), where the odds API becomes a real-time odds API — push over WebSocket, real-time over REST — and the stream sends each price change as it happens. Polling is enough for pre-match odds tables, scheduled jobs, research, and pages people refresh; the stream earns its price for in-play products, line-move alerts, cross-book scanning, and wide scopes where most prices don’t change between polls. This guide covers what polling costs (with data-point counts you can reproduce), how the WebSocket works, a Python client that streams one game, a decision table, and which plan fits.

How REST polling works, and what it costs

A REST client asks for the current state on a schedule, and each response returns the full state for its scope. Two things set the cost of keeping that state current: your plan’s delay and the size of each poll.

The delay sets a freshness floor

Each plan’s REST prices trail the market by a fixed delay, which metered responses report in the X-Data-Delay-Seconds header. Polling faster than the delay never gets you closer to the market than the delay itself, and between polls what you show keeps aging. So the age of a price on your screen is the delay plus the time since your last poll:

Plan Delay Poll every Age of the price you show
Free 5 min 5 minutes 5 to 10 minutes
Starter 60 sec 60 seconds 1 to 2 minutes
Pro 30 sec 30 seconds 30 to 60 seconds
Ultra and up, REST Real-time 10 seconds Up to 10 seconds
Ultra and up, WebSocket Real-time Pushed Sub-second

Free covers pre-match lines only; live in-game odds start on Starter.

Every poll is billed in full

The API meters data points, not requests. For event responses, the rate limits docs give the rule: one point per returned event, one for its score, and one per returned price (one book’s price on one side of one line), plus one more for each event carrying live game state, which Ultra and up return. A successful response is billed for every row it returns, whether or not anything changed since your last poll. On Free, Starter, and Pro, the documented pattern is to repeat a narrow snapshot; the changes-only /markets/delta endpoint is for known zero-delay access, which starts on Ultra.

These counts were checked on October 5 and 6, 2026, with main_line=true and hide_closed=true:

Request Events Prices X-Datapoints
One live NFL game, in-play moneyline, spread, and total; Pinnacle, DraftKings, BetMGM, FanDuel 1 24 27
Sunday’s NFL slate (October 11, offset=240), the same four books 13 256 282
Sunday’s NFL slate, all 25 books 13 1,703 1,729

“All 25 books” means the 25 sportsbooks and exchanges listed on API pricing, filtered explicitly by affiliate_ids=2,3,4,6,7,9,11,12,14,16,18,19,21,22,23,24,25,26,28,29,30,31,32,33,34.

The live game’s 27 includes the live-game-state point; on a plan without live game state, the same response is 26. To check the four-book slate yourself:

curl -s -o /dev/null -D - \
  "https://therundown.io/api/v2/sports/2/events/2026-10-11?market_ids=1,2,3&affiliate_ids=3,19,22,23&main_line=true&hide_closed=true&offset=240" \
  -H "X-TheRundown-Key: $THERUNDOWN_API_KEY" | grep -i -E '^x-datapoints(-breakdown)?:'

On October 6, 2026, it returned:

x-datapoints: 282
x-datapoints-breakdown: events=13,odds=256,scores=13

To check the all-25-books slate, swap in that affiliate_ids list:

curl -s -o /dev/null -D - \
  "https://therundown.io/api/v2/sports/2/events/2026-10-11?market_ids=1,2,3&affiliate_ids=2,3,4,6,7,9,11,12,14,16,18,19,21,22,23,24,25,26,28,29,30,31,32,33,34&main_line=true&hide_closed=true&offset=240" \
  -H "X-TheRundown-Key: $THERUNDOWN_API_KEY" | grep -i -E '^x-datapoints(-breakdown)?:'

On October 6, 2026, it returned:

x-datapoints: 1729
x-datapoints-breakdown: events=13,odds=1703,scores=13

Books post, move, and pull lines all week, and a price a book hasn’t posted isn’t returned or charged, so your count will differ.

Cadence math

Points per day are points per poll times polls per day. Polling every 60 seconds is 1,440 polls a day, and every 30 seconds is 2,880.

Scope Points per poll Every 60 seconds Every 30 seconds
Sunday slate, four books 282 406,080 a day 812,160 a day
Sunday slate, all 25 books 1,729 2,489,760 a day 4,979,520 a day

A four-book, main-line slate fits comfortably: polled every 30 seconds around the clock, it comes to about 24 million points in a 30-day month, a fifth of Pro’s 125 million. Widen it to all 25 books and the same cadence is about 149 million, more than Pro includes, for one league’s main lines before any props or alternate lines. On Starter, all 25 books every 60 seconds is about 75 million a month against an allowance of 25 million. Narrowing the scope (fewer books, main_line=true, only the games you show) is the first fix. The second is to stop paying for rows that didn’t change, which is what the stream does.

How the WebSocket works

On Ultra and up, your server holds a connection open and receives each price change as it happens — a live odds API over WebSocket for every book you filter in. The WebSocket reference documents two V2 endpoints:

  • wss://therundown.io/api/v2/ws/markets streams price changes only, filtered in the connection URL. It’s the simplest way to stream odds.
  • wss://therundown.io/api/v2/ws is multiplexed: one connection carries several subscriptions, each a JSON subscribe message naming a channel (markets, scores, plays, stats, futures, or live) and its own filters.

Authentication. Send X-TheRundown-Key in the upgrade request from a server-side client, over wss:// only (ws:// connections are rejected). Native browser WebSocket clients can’t set custom headers, so a browser app connects to your own authenticated backend, which holds the stream. On metered REST responses, the X-Websocket-Access header says whether the key can connect.

Filters. The markets endpoint takes sport_ids, event_ids, market_ids, affiliate_ids, and main_line=true, comma-separated and all optional. Without filters you get every sport and market, which is a lot of traffic. On the multiplexed endpoint, the ID filters (sport_ids, event_ids, market_ids, affiliate_ids) go in params as JSON arrays, and main_line stays a boolean (main_line: true). One detail to plan for on the markets endpoint: market_ids=1,2,3 also delivers the in-play moneyline, spread, and total, and those messages carry the in-play market IDs 41, 42, and 43, so match both sets.

Messages. Each message is one price change for one participant, market, and sportsbook, not a market snapshot. Trimmed from the reference:

{
  "meta": { "type": "market_price", "version": "v2", "timestamp": 1772495104 },
  "data": {
    "event_id": "9b9d0cf6007fdaeb15c3a1888dcfd5df",
    "affiliate_id": 26,
    "market_id": 3,
    "normalized_market_participant_id": 10,
    "line": "1.5",
    "price": "-117",
    "previous_price": "-122.0000",
    "price_delta": 5,
    "is_main_line": true,
    "sport_id": 7,
    "updated_at": "2026-03-02T23:44:44Z"
  }
}

previous_price shows the prior price without a lookup when the message includes it (a new or closing row may omit it), and price_delta carries the numeric change when present, so read both with a fallback; and normalized_market_participant_id matches the participant id in REST responses. The multiplexed endpoint wraps the same payload in an envelope carrying your subscription id and sequence numbers.

Heartbeats and reconnects. Every endpoint sends {"meta": {"type": "heartbeat"}, "data": {"now": "..."}} every 15 seconds. If messages stop, treat the connection as dead and reconnect instead of waiting for a close event; the streaming guide uses 60 seconds, four missed heartbeats. Reconnect with exponential backoff (1 second, doubling, capped at 30 seconds, with jitter), resubscribe on the multiplexed endpoint, since subscriptions end with the connection, and restore state from REST before applying new updates, because the stream carries only changes.

Metering. WebSocket traffic doesn’t count against your requests-per-second limit, but it is metered: each pushed price message is one data point, snapshot frames on the multiplexed endpoint are billed like the equivalent REST read, and heartbeats are free. With main_line=true, alternate-line updates aren’t delivered or metered.

Connection caps. Ultra allows one concurrent connection per account with up to three multiplexed subscriptions; the plans above it allow more (see the plans table below). The cap is account-wide across every API key and endpoint, and a connection over it is rejected at upgrade with a 429. On Ultra, that makes the multiplexed endpoint the natural choice when you want more than odds: one connection with, say, a markets subscription and a scores subscription. A subscription isn’t a single game; one can cover a whole league:

{
  "action": "subscribe",
  "id": "nba-markets",
  "channel": "markets",
  "params": { "sport_ids": [4], "market_ids": [1, 2, 3] }
}

The server acknowledges with {"type": "subscribed", "id": "nba-markets", "sequence": 12, "message": "subscribed to markets"}, then sends updates tagged with that id.

A minimal Python streaming client

This client follows the pattern in the docs: a server-side connection to the markets endpoint, a REST baseline for the same scope, each pushed change applied as it arrives, and a reconnect with backoff that reloads the baseline every time. It tracks one game’s main moneyline, spread, and total at four books. It uses websockets 14 or later (pip install "websockets>=14" requests), whose additional_headers option puts the key on the handshake. Set EVENT_ID to an event_id from GET /sports/{sport_id}/events/{date}; the developer guide shows that call.

import asyncio
import json
import os
import random

import requests
import websockets

API = "https://therundown.io/api/v2"
KEY = os.environ["THERUNDOWN_API_KEY"]
EVENT_ID = os.environ["EVENT_ID"]  # an event_id from GET /sports/{sport_id}/events/{date}
BOOKS = {3: "Pinnacle", 19: "DraftKings", 22: "BetMGM", 23: "FanDuel"}
MARKETS = {1: "moneyline", 2: "spread", 3: "total", 41: "moneyline", 42: "spread", 43: "total"}
FILTERS = f"event_ids={EVENT_ID}&market_ids=1,2,3&affiliate_ids={','.join(map(str, BOOKS))}"
WS_URL = f"wss://therundown.io/api/v2/ws/markets?{FILTERS}&main_line=true"
STALE_AFTER = 60  # seconds with no message at all; heartbeats arrive every 15

board = {}  # (market_id, participant_id, affiliate_id) -> (line, price)
names = {}  # participant_id -> name, from the REST snapshot


def odds(value):
    """American odds as text; 0.0001 is the documented off-the-board value."""
    if value is None:
        return "new"
    number = float(value)
    return "off board" if number == 0.0001 else f"{number:+.0f}"


def load_snapshot():
    """REST baseline for the same scope; in-play markets use IDs 41, 42, 43."""
    resp = requests.get(
        f"{API}/events/{EVENT_ID}",
        params={
            "market_ids": "1,2,3,41,42,43",
            "affiliate_ids": ",".join(map(str, BOOKS)),
            "main_line": "true",
            "hide_closed": "true",
        },
        headers={"X-TheRundown-Key": KEY},
        timeout=30,
    )
    resp.raise_for_status()
    board.clear()
    for event in resp.json().get("events") or []:
        for market in event["markets"]:
            for participant in market["participants"]:
                names[participant["id"]] = participant["name"]
                for line in participant["lines"]:
                    for book, price in line["prices"].items():
                        key = (market["market_id"], participant["id"], int(book))
                        board[key] = (line.get("value"), price["price"])
    print(f"snapshot: {len(board)} prices, {resp.headers.get('X-Datapoints')} data points")


def apply(update):
    """Upsert one pushed price change: one participant, market, and book."""
    market_id = update["market_id"]
    participant_id = update["normalized_market_participant_id"]
    book = update["affiliate_id"]
    board[(market_id, participant_id, book)] = (update["line"], update["price"])
    market = MARKETS.get(market_id, market_id)
    line = "" if market == "moneyline" else f" {update['line']}"
    print(
        f"{names.get(participant_id, participant_id)} {market}{line} "
        f"{BOOKS.get(book, book)}: {odds(update.get('previous_price'))} -> {odds(update['price'])}"
    )


async def stream():
    delay = 1
    while True:
        try:
            async with websockets.connect(
                WS_URL, additional_headers={"X-TheRundown-Key": KEY}
            ) as ws:
                # Connect first, then load the REST baseline, so no change falls
                # between the two; frames that queue meanwhile apply in order.
                await asyncio.to_thread(load_snapshot)
                delay = 1
                while True:
                    msg = json.loads(await asyncio.wait_for(ws.recv(), STALE_AFTER))
                    if msg.get("meta", {}).get("type") == "market_price":
                        apply(msg["data"])
        except websockets.InvalidStatus as exc:
            if exc.response.status_code in (401, 403):
                raise  # bad key, or a plan without WebSocket access
            reason = f"HTTP {exc.response.status_code}"
        except (OSError, TimeoutError, asyncio.TimeoutError, websockets.ConnectionClosed,
                websockets.InvalidHandshake,
                requests.RequestException) as exc:
            reason = repr(exc)
        wait = delay + random.uniform(0, 1)
        print(f"reconnecting in {wait:.1f}s ({reason})")
        await asyncio.sleep(wait)
        delay = min(delay * 2, 30)


asyncio.run(stream())

Three details are easy to miss. First, the client connects before it loads the REST baseline, so no change can fall in the gap; frames that arrive during the REST call apply in order afterward. Second, the REST request names 41,42,43 so the baseline matches what the stream carries once the game is live (the events endpoint also expands 1,2,3 to the in-play IDs; only /markets/delta filters market IDs literally). Third, with main_line=true the stream alone won’t keep the main line exact: a switch of which line is the main one surfaces only on that line’s next price update, and this client keeps a closed main line’s last price in board until its next REST reload. The WebSocket reference recommends pairing the stream with a periodic REST refresh (main_line=true) for the authoritative current main; this minimal client refreshes only on reconnect, so add a timer if that matters for your use. A 429 at connect usually means the account is at its connection cap, though an account that has reached its data-point limit or request rate gets a 429 too; the client backs off and retries either way.

Run at 01:20 UTC on October 6, 2026, during the first half of Monday night’s Falcons at Saints game, it printed (trimmed):

snapshot: 18 prices, 21 data points
Over total 58.5 FanDuel: -102 -> -136
Under total 58.5 FanDuel: -130 -> +102
Atlanta Falcons spread -6.5 DraftKings: -111 -> -129
New Orleans Saints spread +6.5 DraftKings: -119 -> -102
New Orleans Saints moneyline DraftKings: +208 -> +243
Over total 58.5 DraftKings: -105 -> -121
Atlanta Falcons moneyline DraftKings: -287 -> -342
Under total 58.5 DraftKings: -125 -> -109
Atlanta Falcons moneyline BetMGM: -250 -> -300
New Orleans Saints moneyline BetMGM: +190 -> +230

The baseline cost 21 points: 18 open prices at that moment, plus the event, its score, and live game state. After that, each printed line is one pushed message and one data point.

Polling vs. streaming on the same scope

Snapshots bill every row on every poll; a sports betting odds stream bills only the price messages it pushes. Which costs less depends on how often your scope changes compared with how often you’d poll it, so measure your own scope rather than borrow someone else’s count:

  • A wide, quiet scope — many games, main lines, days before kickoff — has most rows go unbilled between changes, so the stream tends to cost far less than polling the same rows on a schedule.
  • A narrow, busy scope — one live game, moving every few seconds — leaves little room for a poll to save anything; the stream can cost as much as, or more than, polling it every 30 seconds, and in return delivers every move instead of a stale picture between polls.

To measure either case yourself, run the client above against the scope you care about: it prints snapshot: N prices, M data points for the REST baseline, then one line per pushed message, each one a data point whether or not the shown price actually moved on that message. Count the printed lines over a few minutes and compare that to what polling the same scope on your plan’s cadence would bill, using the per-poll counts from the cadence-math table above. As a worked example with round, hypothetical numbers rather than a measured count: a scope pushing 200 messages a minute for three hours comes to 36,000 points, a small fraction of Ultra’s 500,000,000 a month.

Lines move faster near kickoff and in-game, so what you measure will vary by the minute. The pattern holds generally, though: the wider the scope and the quieter the market, the more the stream saves; the narrower and busier, the more you’re paying for freshness instead of saving. On Ultra, polling /markets/delta also bills one point per changed row, so the choice there is push versus pull: hold a connection, or run a polling loop that learns of each change up to one interval late and counts against Ultra’s 10 requests per second.

Which to use: a decision table

Use case REST polling WebSocket (Ultra and up)
Pre-match odds table or dashboard Enough: Pro every 30 to 60 seconds for a table your own users see. For an internal table, Starter every 60 seconds, or Free every 5 minutes for a game or two (a full slate at that cadence hits Free’s daily cap in a few hours) Only if the page should update itself in real time
Line-move alerts You learn of a move 30 to 60 seconds late on Pro, 1 to 2 minutes late on Starter Each move arrives as it happens, usually with the previous price in the message
In-play odds A 30- or 60-second delay lags a live market The fit: every in-play change from the books you choose
Arbitrage and middles scanning Snapshots 30 to 60 seconds old can show price gaps that have already closed Every book’s changes on one connection; confirm prices before acting
Many games and books at once Cost grows with rows times polls Cost grows with changes, often far lower on a wide pre-match scope
Backtesting and models The right tool: odds history and closing-line endpoints are REST Not a fit: the stream carries live changes, with no history to replay
Scheduled jobs and serverless The right tool Needs a long-running process

A scanner that sees every book’s price as it changes finds a gap sooner; it can’t promise the gap is still there when you act. Prices can move before both sides are placed, and books can limit accounts or void bets. The arbitrage betting guide covers the math and the risks.

REST is enough when people read what you build and a price 30 seconds to a few minutes old is fine: pre-match tables, daily reports, research, and anything that runs on a schedule. A four-book slate polled every 30 seconds on Pro uses about a fifth of Pro’s allowance. Free’s three books at five-minute polls cover a prototype or a game or two — a full 13-game slate at that cadence reaches Free’s daily cap in about six hours (see the cadence math in what the free plan includes) — and Free and Starter are licensed for internal or personal use only, so a table or dashboard shown to your own users needs Pro.

The stream earns Ultra when the moment a price moves is the point: an in-play product, alerts users act on, a scanner comparing many books, or a wide scope where re-buying unchanged rows on every poll costs more than the changes do. On the multiplexed endpoint the same connection can carry scores, play-by-play, and live game state beside the odds.

The hybrid pattern

The efficient polling guide notes that many production apps use both transports, and the client above is the core of that pattern:

  1. Bootstrap from REST. Load a narrow snapshot for the scope you show.
  2. Stream changes. Upsert each pushed message by market, participant, and book.
  3. Resync after every reconnect. Reload REST state before trusting new updates. On the multiplexed endpoint you can instead subscribe with "snapshot": true, bounded by event_ids or by sport_ids plus date, and the server sends current state before the updates begin.
  4. Keep REST for the rest. History, opening and closing lines, reference data, and any service or key without WebSocket access stay on REST.

Plans: delay, data points, and WebSocket capacity

Prices and limits below are current as of October 2026; see API pricing for the live numbers.

Plan Price Data points Delay WebSocket Rate limit
Free $0, no card 20,000/day (200,000/month) 5 min No 1 req/sec
Starter $49/month 25,000,000/month 60 sec No 2 req/sec
Pro $149/month 125,000,000/month 30 sec No 5 req/sec
Ultra $399/month 500,000,000/month Real-time 1 connection, 3 subscriptions 10 req/sec
Super $649/month 1,250,000,000/month Real-time 3 connections, 5 subscriptions each 15 req/sec
Mega $999/month 2,500,000,000/month Real-time 5 connections, 10 subscriptions each 20 req/sec
Max $2,499/month 12,500,000,000/month Real-time 10 connections, 25 subscriptions each 50 req/sec
Enterprise Custom Custom Real-time 50 connections, 50 subscriptions each Custom

Free covers DraftKings, FanDuel, and BetMGM pre-match lines; Starter and up include all 25 books and live in-game odds, Pro adds opening and closing lines and commercial end-user display, and Ultra adds futures and 90-day odds history. Annual billing lowers Starter to $39 a month, Pro to $119, and Ultra to $319. The extra connections on Super and up let separate services each hold their own stream, such as a production consumer and a shadow consumer side by side.

Where to go next

Questions

Does TheRundown have a WebSocket odds API?
Yes. wss://therundown.io/api/v2/ws/markets streams price changes, filtered in the connection URL, and wss://therundown.io/api/v2/ws carries several subscriptions (markets, scores, plays, stats, futures, and live) over one connection. Each market message is one price change for one participant, market, and sportsbook. WebSocket access starts on Ultra ($399 a month).
Which plan includes WebSocket, and how many connections do I get?
Ultra ($399 a month) includes 1 concurrent connection with up to 3 multiplexed subscriptions. Super ($649) allows 3 connections with 5 subscriptions each, Mega ($999) 5 with 10, Max ($2,499) 10 with 25, and Enterprise 50 with 50. The limits are account-wide across every API key and endpoint, so extra keys add no connections, and a connection over the cap is rejected at upgrade with a 429. Free, Starter, and Pro are REST only.
Do WebSocket messages cost data points?
Yes. WebSocket traffic does not count against your requests-per-second limit, but each pushed price message is one data point, snapshot frames are billed like the equivalent REST read, and heartbeats are free. The difference from REST is what you pay for: a REST snapshot bills every row it returns on every poll, changed or not, while the stream bills only the price messages it pushes to you. With main_line=true, alternate-line updates are not delivered or metered.
How fast are WebSocket odds updates?
Real-time odds updates are sub-second across every book, per the API docs. REST prices trail the market by five minutes on Free, 60 seconds on Starter, and 30 seconds on Pro, and are real-time on Ultra and up. Live game data on the plays, stats, and live channels is a separate case: it trails the on-field action by roughly 15 to 20 seconds, in line with the typical broadcast delay.
Can I filter the stream by sport, sportsbook, or market?
Yes. The markets endpoint takes sport_ids, event_ids, market_ids, affiliate_ids, and main_line in the connection URL; the multiplexed endpoint takes the ID filters as JSON arrays in each subscription, and main_line as a boolean. On the markets endpoint, market_ids=1,2,3 also delivers the in-play moneyline, spread, and total, whose messages carry market IDs 41, 42, and 43.
Should I poll or stream sports odds?
Poll when prices a few minutes or 30 to 60 seconds old are fine: pre-match tables, scheduled jobs, research, and backtesting. Stream when you act on moves as they happen: in-play products, line-move alerts, cross-book scanning, and wide scopes where most prices do not change between polls. Many production apps use both, with REST for the starting state and recovery and the stream for changes.
What happens if my WebSocket connection drops?
Reconnect. The server sends a heartbeat every 15 seconds; if nothing arrives for well past that (the streaming guide uses 60 seconds), treat the connection as dead. Back off exponentially from 1 second to a 30-second cap with jitter, resubscribe on the multiplexed endpoint, and restore state from REST before applying new updates, because the stream carries only changes.
Can I connect to the WebSocket from a browser?
Not directly. The key goes in the X-TheRundown-Key header of the upgrade request, and native browser WebSocket clients cannot set custom headers. Hold the stream on your server and relay updates to browsers through your own authenticated backend, which also keeps the key out of browser code.

Real-time odds start on Ultra, $399 a month.

Ultra adds the WebSocket stream (1 connection, up to 3 subscriptions), real-time REST, futures, and 500,000,000 data points a month. REST polling works on every plan, with a 5-minute, 60-second, or 30-second delay below Ultra.