WebSocket Odds API: Catch 1,438 Price Moves in 10 Minutes
A WebSocket odds API pushes each price change to you the moment the feed sees it, so you stop asking “has anything moved?” on a timer. OddsPapi streams odds over wss://v5.oddspapi.io/ws on its B2B plan. The free plan gives you the pre-game REST API and historical odds, 250 requests a month, across every sport and bookmaker.
The useful question is how much a poller loses. We measured it on 8 October 2026. Between 13:10 and 13:20 UTC we polled two soccer boards every 4 seconds and counted every price that changed. Then we re-sampled the same data at slower poll rates to see what each rate would have caught.
What a Poll Misses: 10 Minutes, Two Boards
We asked both boards for the same 10 bookmakers: pinnacle, singbet, betfair-ex, 1xbet, draftkings, fanduel, polymarket, stake, betano and kalshi. Eight of them returned prices on the in-play board and nine on the pre-match board. A “move” is a price whose value and changedAt both changed between two polls. Two changes inside one 4-second gap count as one, so every number below is a floor.
| Board | Prices | Moves in 10 min | Seen at 16 s | Seen at 60 s | Seen at 120 s |
|---|---|---|---|---|---|
| In play: V-League, Viettel v Hong Linh Ha Tinh | 1,460 | 1,438 (144 a minute) | 796 (55%) | 438 (30%) | 316 (22%) |
| Pre-match: Veikkausliiga, HJK v VPS, 1 h 50 m out | 2,378 | 675 (68 a minute) | 502 (74%) | 165 (24%) | 133 (20%) |
The books move in two different ways, and the split decides whether you need the stream.
- Exchanges and prediction markets tick. In play, Betfair Exchange moved 1,141 times across 119 prices, so the same prices changed again and again. A 60-second poll saw 193 of those 1,141 moves. Pre-match, Polymarket moved 226 times across 73 prices.
- Sportsbooks reprice in bursts. In play, 1xBet moved 102 prices, Betano 47, DraftKings 36 and Pinnacle 23. Each book stamped its whole batch with one
changedAtsecond, and no price moved twice. A 120-second poll caught all of them, late.
Pinnacle sits between the two before kick-off. It made 268 moves on the HJK board across 163 prices, in nine bursts, and a 60-second poll saw 50 of them.
So the answer depends on what you trade. If you read a sportsbook’s line once before you bet, a poll every minute or two catches each reprice. If you trade against an exchange ladder, a 60-second poll showed you about one move in six on the in-play board.
What Polling Costs
A missed move is one cost. The bill for the polls is the other.
| Poll plan for one fixture | Requests an hour | Data an hour |
|---|---|---|
| Every 4 s, 10 books requested (1.0 MB a call on HJK v VPS) | 900 | about 0.9 GB |
| Every 4 s, all books (24.5 MB a call, 254 books) | 900 | about 22 GB |
| Every 60 s, all books | 60 | about 1.5 GB |
At one call every 4 seconds, the free plan’s 250 monthly requests last about 17 minutes on one fixture. Two more limits apply to REST. Back-to-back /v4/odds calls return HTTP 429 with a retryMs field of about 300 ms, so a poller has to pace itself. And a poll returns the full state of every price you ask for, whether it moved or not. On the in-play board, 1,460 prices came back on each call. The median call carried 2 changed prices, and 48 of 149 carried none.
REST Polling vs the OddsPapi WebSocket
Every row in the WebSocket column comes from the public reference at docs.oddspapi.io, read on 8 October 2026.
| Aspect | REST polling (v4) | WebSocket (v5, B2B plan) |
|---|---|---|
| How you get a change | You ask on a timer | The server pushes it |
| Delay | Up to one poll interval, plus the call | Batched on a fixed window of about 10 ms; only the newest value per price inside one window is sent |
| What each message carries | The full state of every price you asked for | One row per changed price, keyed fixtureId:bookmaker:outcomeId:playerId |
| Filters | fixtureId, bookmakers per call |
channels, sportIds, tournamentIds, fixtureIds, bookmakers, set once at login |
| Channels | One endpoint per data type | odds, fixtures, scores, bookmakers, futures, currencies and more on one socket |
| Recovery after a drop | Poll again | Resume from a stored serverEpoch and per-channel entryId; a snapshot_required message tells you to re-fetch by REST |
| Size on the wire | JSON | JSON, MessagePack, or zstd (the docs quote about 5-6x smaller on odds, 7-9x with trained dictionaries) |
| Limits | Request quota; short gap between /odds calls |
Up to 5 connections per key group; the server closes a client that cannot keep up |
The stream sends state, not a tick ledger. If two changes to one price land inside one 10 ms window, you get the second. For every tick and for opening and closing lines, the docs point you to the historical endpoints, which the free plan also includes.
How the Stream Works
The flow below is the one the docs recommend for production. Connect first, then take a REST snapshot, so nothing falls in the gap between the two.
- Open
wss://v5.oddspapi.io/wsand send aloginmessage within 10 seconds. The login carries your key, your channels and your filters. - After
login_ok, apply each update to your store, one row per price key. - Fetch a REST snapshot and merge it. Per key, keep the side with the newer
changedAt. - Store
serverEpochand the lastentryIdper channel. - On reconnect, send both to resume. If the server answers
snapshot_required, re-fetch the snapshot and merge again.
A login message in the shape the docs define, with channels and filters:
{
"type": "login",
"apiKey": "YOUR_API_KEY",
"receiveType": "zstd",
"channels": ["odds", "fixtures", "scores"],
"sportIds": [10],
"bookmakers": ["pinnacle", "betfair-ex", "polymarket"]
}
Every update arrives in one envelope, shown here in the documented shape. ts is the send time and entryId is your resume cursor:
{
"channel": "odds",
"type": "UPDATE",
"payload": { "...": "..." },
"ts": 1765497902846,
"entryId": "1765497902846-3221"
}
An odds payload row carries price, active, marketId, limit (the largest stake the book accepts), meta (exchange ladders) and two clocks: bookmakerChangedAt, when the book says it moved, and changedAt, when OddsPapi saw it. The full client examples, in Node and Python, are on the quickstart page. We did not run them for this post, because the stream needs a B2B key.
Build the REST Half on a Free Key
The snapshot-and-merge half of that flow runs on the free plan, and you can measure your own board with it. The code below finds the next soccer kick-offs, reads one board in the same key shape the stream uses, and counts moves for 60 seconds. A full run costs about 15 to 35 of your 250 monthly requests, depending on how many thin boards it skips.
Step 1: Find a Board and Read It
import os
import time
import requests
from datetime import date, datetime, timedelta, timezone
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"
# Step 1: find soccer fixtures that carry odds (from/to is required)
today = date.today()
fixtures = requests.get(f"{BASE_URL}/fixtures", params={
"apiKey": API_KEY,
"sportId": 10,
"from": today.isoformat(),
"to": (today + timedelta(days=1)).isoformat(),
"hasOdds": "true",
}).json()
soon = sorted((f for f in fixtures if f.get("statusName") == "Pre-Game"
and f["startTime"] > datetime.now(timezone.utc).isoformat()),
key=lambda f: f["startTime"])
print(f"{len(fixtures)} fixtures with odds, {len(soon)} still to kick off")
# Step 2: one snapshot of one board, keyed the way the stream keys it
def snapshot(fixture_id, bookmakers):
params = {"apiKey": API_KEY, "fixtureId": fixture_id, "bookmakers": bookmakers}
r = requests.get(f"{BASE_URL}/odds", params=params)
if r.status_code == 429: # /odds wants a short gap between calls
time.sleep(r.json()["error"].get("retryMs", 1000) / 1000)
r = requests.get(f"{BASE_URL}/odds", params=params)
r.raise_for_status()
rows = {}
for book, data in r.json().get("bookmakerOdds", {}).items():
for market_id, market in (data.get("markets") or {}).items():
for outcome_id, outcome in (market.get("outcomes") or {}).items():
for player_id, p in (outcome.get("players") or {}).items():
key = f"{fixture_id}:{book}:{outcome_id}:{player_id}"
rows[key] = {"price": p["price"], "active": p.get("active"),
"changedAt": p.get("changedAt")}
return rows
BOOKS = "pinnacle,betfair-ex,1xbet,polymarket"
for fx in soon[:20]: # the next kick-offs move fastest before the whistle
board = snapshot(fx["fixtureId"], BOOKS)
if len(board) > 500: # skip thin boards
break
time.sleep(0.5) # /odds wants a short gap between calls
print(f"{fx['participant1Name']} v {fx['participant2Name']}: {len(board)} prices")
Step 2: Merge a Snapshot by changedAt
This is the merge rule from the docs. It works the same whether the newer row comes from a REST poll or from the socket.
def merge(store, rows):
"""Keep whichever side has the newer changedAt, per key (ISO strings sort by time)."""
changed = 0
for key, row in rows.items():
old = store.get(key)
if old is None or (row["changedAt"] or "") > (old["changedAt"] or ""):
store[key] = row
changed += 1
return changed
Step 3: Count the Moves on Your Board
import time
from collections import Counter
def count_moves(fixture_id, bookmakers, seconds=300, every=5):
"""Poll one board and count price moves per book, using changedAt."""
seen, moves = {}, Counter()
stop = time.time() + seconds
while time.time() < stop:
for key, row in snapshot(fixture_id, bookmakers).items():
book = key.split(":")[1]
if key in seen and row["changedAt"] != seen[key]["changedAt"] \
and row["price"] != seen[key]["price"]:
moves[book] += 1
seen[key] = row
time.sleep(every)
return moves
moves = count_moves(fx["fixtureId"], BOOKS, seconds=60)
for book in BOOKS.split(","):
n = moves[book]
print(f"{book:12} {n:4} moves in 60 s")
Our run at 13:34 UTC on 8 October 2026, on a Turkish Cup fixture about 25 minutes before kick-off:
214 fixtures with odds, 174 still to kick off
Denizli Idmanyurdu 1959 Spor Kulubu v Serikspor: 529 prices
pinnacle 53 moves in 60 s
betfair-ex 0 moves in 60 s
1xbet 0 moves in 60 s
polymarket 0 moves in 60 s
Pinnacle moved 53 prices in that minute while the other three books held still. A run on the same board one minute earlier counted zero moves on all four books, and an earlier one counted 329 Pinnacle moves. One minute tells you little, so run it on your own fixtures before you pick a poll rate. A merge of a fresh snapshot into the first one, about a minute later, updated 59 rows.
A few notes before you point this at a live board. In-play REST odds come with the paid custom plan, not the free one. Pace your calls: the 429 body tells you how long to wait in retryMs. And dedupe skins before you count books: several slugs can quote byte-identical prices, as the consensus odds guide shows.
Who Needs the Stream
| What you build | What to use | Why, from the run above |
|---|---|---|
| Exchange or prediction-market trading | WebSocket | A 60-second poll saw 193 of 1,141 Betfair Exchange moves in play |
| In-play models and alerts | WebSocket | 144 moves a minute on one in-play board, 8 books |
| Steam alerts on sharp books before kick-off | WebSocket, or REST every few seconds on a short list | Pinnacle made 268 moves in 10 minutes on one pre-match board |
| Line shopping on sportsbooks before you bet | REST | Sportsbooks repriced in bursts; a 120-second poll caught every burst |
| Backtests and closing-line value | REST historical odds | History holds every snapshot; the stream holds state |
For the sportsbook-lag side of this, the soft book lag study times how long 1xBet and bet365 take to follow a Pinnacle move. For an alert bot on top of REST polling, start with the steam move detector. In-play polling itself is covered in the live betting API guide, and backtests in how to backtest with free historical odds.
Building for a sportsbook or a trading desk?
The B2B plan adds the stream on top of the same data: per-channel cursors with resume and replay, opening and closing line values for CLV, and a per-outcome settlement result on /v4/settlements once a fixture finishes. The odds feed provider guide walks through the desk setup, and the reference lives at docs.oddspapi.io. Pricing is usage-based, so a desk pays for the bookmakers, sports and request volume it reads.
Frequently Asked Questions
Does OddsPapi have a WebSocket odds API?
Yes. The gateway is wss://v5.oddspapi.io/ws, and it streams odds, fixtures, scores, bookmaker status, futures and currency rates. WebSocket access comes with the B2B plan.
What does the free plan include?
The pre-game REST API, every sport and bookmaker, historical odds, and 250 requests a month. That is enough to build and test the REST half of a streaming client.
How fast does the stream deliver a change?
The odds channel batches on a fixed window of about 10 ms. Inside one window, only the newest value per price is sent. Each row carries changedAt, so you can measure the delay on your side.
How often do odds change?
On 8 October 2026, an in-play V-League board moved 144 prices a minute across the 8 books that priced it, most of them on Betfair Exchange. A pre-match Veikkausliiga board 1 hour 50 minutes from kick-off moved 68 a minute, led by Pinnacle and Polymarket.
Can I get every tick from the stream?
The stream sends the latest state of each price. For every recorded snapshot, and for opening and closing lines, use the historical odds endpoint.
What happens if my connection drops?
Reconnect with your stored serverEpoch and the last entryId per channel, and the gateway replays what you missed. The docs example shows a 60-second replay window. After a longer gap, the server sends snapshot_required and you re-fetch a REST snapshot.
Start With the Snapshot
Every streaming client needs the REST snapshot and the merge rule, and both run on a free key across 350+ bookmakers with free historical odds. Build that half now, measure your own board, and you will know from the numbers whether your strategy needs the stream.