WebSocket Odds API: Catch 1,438 Price Moves in 10 Minutes

How To Guides January 24, 2026

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 changedAt second, 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.

  1. Open wss://v5.oddspapi.io/ws and send a login message within 10 seconds. The login carries your key, your channels and your filters.
  2. After login_ok, apply each update to your store, one row per price key.
  3. Fetch a REST snapshot and merge it. Per key, keep the side with the newer changedAt.
  4. Store serverEpoch and the last entryId per channel.
  5. 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.

Get your free OddsPapi API key