An odds feed API delivers bookmaker prices to your system as they change. Sportsbooks use one to benchmark their own lines, media sites use one to show prices next to match coverage, and odds comparison sites are built on top of one. The feed decides how fresh those prices are, how much data you move, and how much code you write to keep your copy correct.
This guide explains what an odds feed is, how push and pull delivery differ, what to check when you compare providers, and how the Odds-API.io feed works in practice, with a Python client that was run against the live feed on 19 October 2026.
What an odds feed API is
A sports odds API answers a question: what are the prices for this match right now? An odds feed keeps answering without being asked. Every time a bookmaker moves a line, opens a market or pulls one, the feed sends the change.
Behind any feed sits the same pipeline. Prices are collected from each bookmaker, matched to a single event so the same match from different books lines up, mapped onto common market names, and then delivered. The quality of a feed comes from all four steps. A fast feed with poor event matching gives you the same match twice under two spellings, and a well-matched feed that arrives late is of little use in play.
Push or pull: WebSocket streaming versus REST polling
There are two ways to get the changes. With pull delivery you call a REST endpoint on a timer and compare what comes back with what you hold. With push delivery you open one WebSocket connection and the server sends each change as it happens.
| REST polling | WebSocket push | |
|---|---|---|
| Freshness | Your poll interval | Each change as it lands |
| Requests | One per poll | One connection |
| Your work | Diff each response | Apply each message |
| Good for | Snapshots, low volume | In-play, large books |
Polling is simple and fine for pre-match prices that move a few times an hour. It gets expensive in play, where a football match can change hundreds of times, and your data can be no fresher than the interval you poll at. A stream removes the interval. It asks more of the client, which now has to handle disconnects and know what it missed. A practical production setup uses both: a stream for changes and REST for snapshots and recovery.
What to check when choosing an odds feed provider
Price lists and bookmaker counts are easy to compare. These are the points that decide whether an integration works in production.
Latency you can measure
Ask how quickly a price change at the bookmaker reaches you, and check it yourself. Each message should carry a timestamp you can compare with the time you received it. Measure in play, at peak hours, on the books you care about.
Coverage of the books and markets you need
Count only the bookmakers you will use, in the regions you serve, and check which markets each one carries. A provider with hundreds of books that lacks your main local book is the wrong provider. Check in-play coverage separately from pre-match.
Normalisation of events, participants and markets
Every bookmaker names teams, leagues and markets differently. The feed should give one event id per match across all books, stable participant ids, and one set of market names. Without that you write the matching yourself, and it is the hardest part of the job.
Recovery after a disconnect
Connections drop. Ask what happens next. A good feed numbers its messages so you can resume from the last one you processed. A weaker one leaves you to download everything again or to live with a gap you cannot see.
Removals and suspensions
Prices that disappear matter as much as prices that move. If a suspended market is never removed from your copy, you keep showing a price nobody offers. Find out how the feed signals a pulled market or a removed match.
Results and settlement
Odds and results tend to be needed together. Check whether the same provider streams scores and match status, and whether you can grade bets from the same event ids.
Bandwidth, limits and connections
Ask how many connections a key can hold, how you filter the stream, and whether compression is supported. A full multi-sport stream is a lot of data. Filtering by sport, league or market keeps it to what you use.
How the Odds-API.io odds feed works
Odds-API.io collects odds from 415+ bookmakers across 34 sports and more than 12,000 leagues, and serves them with one event id per match, participant ids and shared market names such as ML, Spread and Totals. The bookmaker list shows which books are covered. The feed is a WebSocket at wss://api.odds-api.io/v3/ws, and it sends updates for the bookmakers selected on your account.
Channels. Set channels to choose what you receive. odds carries created, updated, deleted and no_markets messages. scores sends a message on every score change, and status sends pending, live, settled and cancelled transitions, with the final score on settle. channels is an allowlist: channels=scores,status gives you no odds at all, so list odds as well if you want prices. Results use the same event ids: /v3/settlements returns final scores, and POST /v3/settlements/grade grades submitted bets as won, lost, void, half won or half lost, or reports them as pending or ungradeable.
Filters. markets is required for the odds channel, up to 40 names. You can narrow further by sport (up to 10), leagues (up to 20) or eventIds (up to 50), and by status=live or status=prematch. Each key can hold two connections at once, for example a primary and a standby.
Compression. The client must negotiate permessage-deflate. Since 15 October 2026 a connection without it is refused with HTTP 426. Python websockets, Node ws and browsers negotiate it by default. Python websocket-client cannot, so use websockets instead.
This is an odds message from the live feed, for a women's Chinese Super League match, with the Totals lines shortened:
{
"type": "updated",
"seq": 1803383985,
"timestamp": 1792407804,
"id": "75107580",
"bookie": "SingBet",
"date": "2026-10-19T11:00:00Z",
"markets": [
{"name": "ML", "updatedAt": "2026-10-19T11:02:44.334Z",
"odds": [{"home": "8.50", "draw": "4.60", "away": "1.27"}]},
{"name": "Totals", "updatedAt": "2026-10-19T11:03:24.501Z",
"odds": [{"hdp": 2.25, "over": "1.69", "under": "2.12"},
{"hdp": 2.5, "over": "1.94", "under": "1.86"}]}
]
}Each updated message carries the full set of markets in your filter for that event and bookmaker, including the ones that did not move. Replace what you hold for that event and bookmaker with what arrives. A suspended market is dropped from the payload, so a client that merges keeps it forever. deleted and no_markets tell you to clear the pair.
seq is a global sequence number that only goes up. Store the last one you processed. When you reconnect with lastSeq set to it, the server replays what changed while you were away before it resumes the live stream.
A Python odds feed client that reconnects with lastSeq
The client below keeps a copy of live football odds on the ML and Totals markets, with scores and status on the same connection. It starts with a REST snapshot taken with includeSeq=true and connects from the lowest cursor the snapshot returned. If no football is live yet, it checks again every 5 seconds and only connects once it has a cursor, so no event can go live unseen. The snapshot lists the live events again after its odds requests and fetches any that went live in between, so every event live at the cursor is in the book, and anything later arrives through replay. It keeps one entry per event and bookmaker, replaces markets on every update, stores seq, and reconnects with lastSeq. Recovery builds a new book in a separate dictionary and swaps it in only when every request has succeeded. The back-off only resets after a connection has stayed up for a minute, and a rejected key or bad filter stops the client with the HTTP status instead of retrying. It needs Python 3.10 or later and pip install "websockets>=14" requests.
import asyncio
import json
import os
import time
from urllib.parse import urlencode
import requests
import websockets
KEY = os.environ["ODDS_API_KEY"]
REST = "https://api.odds-api.io/v3"
SPORT, MARKETS = "football", "ML,Totals"
FATAL = {400, 401, 403, 426}
book = {}
scores = {}
last_seq = None
def get(path, **params):
res = requests.get(f"{REST}/{path}", params={"apiKey": KEY, **params},
timeout=(5, 30))
if res.status_code in FATAL:
raise SystemExit(f"{path}: HTTP {res.status_code}")
res.raise_for_status()
return res
def live_events():
events = get("events", sport=SPORT, status="live").json()
return {str(e["id"]): (e["status"], e.get("scores")) for e in events}
def snapshot():
books = ",".join(get("bookmakers/selected").json()["bookmakers"])
fresh, seqs, done = {}, [], set()
while True:
events = live_events()
todo = [i for i in events if i not in done]
if not todo:
if not seqs:
time.sleep(5)
continue
return fresh, events, min(seqs)
for i in range(0, len(todo), 10):
res = get("odds/multi", eventIds=",".join(todo[i:i + 10]), bookmakers=books,
markets=MARKETS, includeSeq="true")
seqs.append(int(res.headers["X-OddsAPI-Seq"]))
for event in res.json():
for bookie, markets in event["bookmakers"].items():
fresh[(str(event["id"]), bookie)] = markets
done.update(todo)
async def retry(fn):
delay = 1
while True:
try:
return await asyncio.to_thread(fn)
except (requests.RequestException, KeyError, ValueError) as err:
print(f"REST failed ({type(err).__name__}), retrying in {delay}s")
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
async def recover():
global book, scores, last_seq
fresh, events, seq = await retry(snapshot)
book, scores, last_seq = fresh, events, seq
print(f"snapshot: {len(book)} event/bookmaker pairs, lastSeq={last_seq}")
def ws_url():
params = {"apiKey": KEY, "sport": SPORT, "status": "live", "markets": MARKETS,
"channels": "odds,scores,status"}
if last_seq:
params["lastSeq"] = last_seq
return "wss://api.odds-api.io/v3/ws?" + urlencode(params)
def apply(msg):
global last_seq
key = (str(msg["id"]), msg.get("bookie"))
if msg["type"] in ("created", "updated"):
book[key] = msg["markets"]
elif msg["type"] in ("deleted", "no_markets"):
book.pop(key, None)
elif msg["type"] in ("score", "status"):
old_status = scores.get(key[0], (None, None))[0]
scores[key[0]] = (msg.get("status", old_status), msg.get("scores"))
if msg.get("seq"):
last_seq = msg["seq"]
ml = next((m["odds"][0] for m in msg.get("markets", []) if m["name"] == "ML"), None)
prices = f'{ml["home"]} / {ml.get("draw", "-")} / {ml["away"]}' if ml else ""
print(f'{msg["type"]:<10} {msg["id"]:<12} {msg.get("bookie", ""):<8} '
f'seq={msg.get("seq", "")} {prices or msg.get("scores", "")}')
async def main():
global book, scores
await recover()
delay = 1
while True:
opened = None
try:
async with websockets.connect(ws_url(), ping_interval=30) as ws:
opened = time.monotonic()
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "welcome":
print(f"connected, lastSeq={last_seq}, bookmakers={msg['bookmakers']}")
scores = await retry(live_events)
book = {k: v for k, v in book.items() if k[0] in scores}
elif msg["type"] == "resync_required" or (
msg["type"] == "created" and not msg.get("seq")):
print("state reset by server:", msg.get("reason", "snapshot"))
await recover()
break
else:
apply(msg)
except websockets.InvalidStatus as err:
code = err.response.status_code
if code in FATAL:
raise SystemExit(f"WebSocket refused: HTTP {code}")
print(f"handshake failed: HTTP {code}")
except (websockets.ConnectionClosedError, websockets.InvalidHandshake,
OSError, asyncio.TimeoutError, TimeoutError) as err:
print(f"connection lost: {type(err).__name__}")
if opened and time.monotonic() - opened > 60:
delay = 1
print(f"reconnecting in {delay}s with lastSeq={last_seq}")
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(main())Retryable errors are logged by exception type, and fatal HTTP errors show only the endpoint and status code. A requests exception can carry the request URL, and that URL contains your API key.
We ran it against the live feed on 19 October 2026 with two bookmakers selected, and closed the connection from the client after 20 seconds to force a reconnect. The output, shortened:
snapshot: 19 event/bookmaker pairs, lastSeq=1803940282
connected, lastSeq=1803940282, bookmakers=['Bet365', 'SingBet']
updated 74714690 Bet365 seq=1803940453 1.333 / 6.000 / 6.000
updated 75653562 Bet365 seq=1803940460 2.100 / 2.625 / 4.333
updated 75107580 Bet365 seq=1803940464 19.000 / 8.000 / 1.083
updated 73737080 Bet365 seq=1803940467 1.333 / 5.500 / 6.500
...
score 73436536 seq= {'home': 0, 'away': 2, 'periods': {'p1': {'home': 0, 'away': 2}}}
...
updated 72962304 Bet365 seq=1803946482 4.333 / 1.727 / 3.500
updated 74090460 Bet365 seq=1803946483 2.875 / 2.750 / 2.750
updated 73436536 Bet365 seq=1803946503 10.000 / 7.500 / 1.143
updated 73322392 Bet365 seq=1803946506 7.000 / 3.400 / 1.533
reconnecting in 1s with lastSeq=1803946506
connected, lastSeq=1803946506, bookmakers=['Bet365', 'SingBet']
updated 72409728 SingBet seq=1803947166 2.00 / 2.22 / 6.70
updated 72409728 Bet365 seq=1803947246 1.909 / 2.300 / 8.000
updated 72217626 Bet365 seq=1803947248 19.000 / 5.500 / 1.167
updated 75107580 Bet365 seq=1803947254 19.000 / 8.000 / 1.083
...The snapshot returned 19 event and bookmaker pairs and the cursor 1803940282, and the first connection resumed from it. After the forced close, the second connection opened with lastSeq=1803946506, and the messages after it carry higher numbers: the changes made while the client was away, followed by the live stream. Replay is latest-state per event and bookmaker. If a price moved five times during the gap, you get the last value once, which is what you need to bring a copy up to date. /odds/movements keeps recorded pre-match price history for a set list of bookmakers, but it is not a complete tick archive.
Score and status messages have no seq and are not replayed. That is why the client reads current scores from /events after every reconnect.
When the replay is not enough: resync
Replay data is kept for up to 24 hours. If your lastSeq is too old for a replay, the server sends the current state of your subscription as created messages and carries on streaming on the same connection. That state replaces your book: pairs it does not include are gone. On 19 October those snapshot messages arrived without a seq, and this client treats such a message as a reset: it rebuilds from REST and reconnects from the new cursor, the same way it handles resync_required. After every reconnect it also drops events that are no longer in the live list from /events, so finished matches do not linger.
If the server cannot send a complete state, it sends resync_required with a reason. The client then rebuilds from REST with the same snapshot function it starts with: the live events from /events, their odds from /odds/multi in batches of 10 with includeSeq=true, and the lowest X-OddsAPI-Seq header across the batches as the new lastSeq. A failed request is retried before the client reconnects, and the old book stays in place until the new one is complete. Reconnecting from the new cursor closes the gap between the snapshot and the stream. Do not adopt the current_seq from the message: the updates between the two would be lost.
REST as a fallback: /odds/multi and /odds/updated
Paid plans include 5,000 REST requests an hour. REST snapshots carry the same market odds as the stream, inside an event response with a bookmakers object. /odds/multi returns odds for up to 10 events in one request, which makes it the right tool for snapshots and the recovery above.
/odds/updated is the polling version of the feed. You pass since, a bookmaker, a sport and markets, which has been required since 19 October 2026. Pass the X-Next-Since response header as since on your next call. Poll promptly and fetch the rest of a truncated response straight away, because the cursor is only accepted while it is no more than 100 seconds old. Consecutive responses can overlap, so the same update may arrive twice.
curl --compressed -i "https://api.odds-api.io/v3/odds/updated?apiKey=YOUR_KEY&since=1792407844&bookmaker=Bet365&sport=football&markets=ML,Totals"
HTTP/2 200
x-next-since: 1792407854
x-server-time: 1792407864From 22 October 2026 at 09:00 UTC each /odds/updated response carries only the markets that changed, so merge responses by event, bookmaker and market name. This is the opposite of the WebSocket rule. A market missing from an /odds/updated response is unchanged, not removed, because the endpoint has no removal signal. If you need to know when markets are pulled, use the stream.
What is the difference between an odds feed and an odds API?
An odds API returns prices when you ask. An odds feed sends changes as they happen. Many providers, Odds-API.io included, offer both on the same data: REST for snapshots and lookups, and a WebSocket stream for changes.
Do I need a WebSocket feed for an odds comparison site?
For pre-match pages that refresh every few minutes, REST polling within the 5,000 requests an hour on paid plans is often enough. For in-play prices, or when you track many books across many leagues, a stream keeps you current with one connection and no poll interval.
Getting started
Plans start with Solo, and the WebSocket feed is an add-on at twice the plan price. See the pricing page for your currency. The WebSocket guide in the docs covers every message type, filter and resync reason. For more on keeping in-play odds fresh, read Live Odds API: Polling vs WebSocket Streaming and Live Odds Stream: Real-Time Betting Odds with WebSocket. The same data is available to AI assistants through the Odds-API.io MCP server.





