Skip to main content
Live Odds API: Polling vs WebSocket Streaming
Back to Blog

Live Odds API: Polling vs WebSocket Streaming

James Whitfield

James Whitfield

•9 min read

A live odds feed has one job: when a bookmaker moves a price during a match, your system should know within seconds, not on the next scheduled refresh. Every team that builds on in-play data hits the same fork early. Do you poll a REST endpoint on a timer, or do you hold a WebSocket open and let updates arrive? Both are supported on the Odds-API.io v3 API, and both are the right answer for different workloads.

This guide walks through the three ways to keep live odds fresh, what each one costs against your rate limit, the message shapes you will parse, and the reconnect logic that separates a demo from something you can leave running through a Saturday.

What live means at the API level

An event is live when its status is live. You can list every in-play match with /events/live, optionally narrowed to one sport, and each row carries the same fields as a pre-match event plus a clock object when the feed has one: the current minute, seconds played, the period, whether the clock is running, and the phase as display text.

curl -G "https://api.odds-api.io/v3/events/live" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "sport=football"

Two things about that clock matter for anything you build on top. The minute and playedSeconds fields are extrapolated server-side and only advance while running is true, so a per-second ticker in your UI should extrapolate client-side from playedSeconds rather than re-fetching. And statusDetail is the feed's own phrase, such as 2nd half or Map 2, passed through as text. Branch on period and running, not on that string.

Odds sit under the event by bookmaker, and each market carries its own updatedAt. That timestamp is the last time the price changed, not the last time it was checked. A market that reads ten minutes old on a live match usually means nothing moved, which is common for a quiet half.

Option one: poll /odds and /odds/multi

The simplest loop fetches the events you care about on a timer. /odds returns one event across up to 30 bookmakers. /odds/multi returns up to ten events in one call, and it counts as a single request against your limit, which changes the arithmetic considerably.

curl -G "https://api.odds-api.io/v3/odds/multi" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "eventIds=73846346,73846350,73846412" \
  --data-urlencode "bookmakers=Bet365,Unibet,SingBet,Betfair Sportsbook"

Every plan carries 5,000 requests an hour, with packages of 10K, 20K and 30K on top. A batch of ten live matches polled every ten seconds is 360 calls an hour, so you can run more than a dozen such batches inside the base allowance. The limit is not the number of matches, it is how often you ask.

Polling suits prototypes, single-match pages, and any product where a ten to fifteen second refresh is fine. It is stateless, easy to reason about, and trivial to retry. Its weakness is that most calls return nothing new, and you are paying for every one of them.

Option two: /odds/updated as a delta poll

If you want polling without re-downloading unchanged prices, /odds/updated returns only the odds that changed since a Unix timestamp. It needs three things: the timestamp, one bookmaker name, and one sport. The since value cannot be more than 90 seconds old, so this is a tight loop by design, not a catch-up endpoint.

SINCE=$(( $(date +%s) - 30 ))
curl -G "https://api.odds-api.io/v3/odds/updated" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "since=$SINCE" \
  --data-urlencode "bookmaker=Bet365" \
  --data-urlencode "sport=Football"

Because it is scoped to a single bookmaker and sport, you run one loop per pair you follow. Five books across two sports is ten loops. That is still far cheaper than full snapshots when only a handful of prices move per interval, and it is the right stepping stone if you are not ready to manage a socket. Once the pair count climbs, the socket starts to win.

Option three: the WebSocket stream

The stream pushes each change as it is committed. You connect once with your filters in the URL, there is no subscribe frame to send, and updates arrive as JSON messages until you close the connection.

wss://api.odds-api.io/v3/ws?apiKey=YOUR_KEY&markets=ML,Spread,Totals&sport=football&status=live

The parameters do the shaping. markets is required for the odds channel and takes up to 20 names. sport narrows to up to ten slugs, leagues to up to 20, eventIds to up to 50, and leagues and eventIds cannot be combined. status=live drops pre-match traffic entirely. The documentation recommends using leagues or eventIds to cut bandwidth, and on a busy football evening that advice is worth taking.

Channels are an allowlist. The default is odds only. If you add scores or status you must list odds too, or the odds stop. channels=odds,scores,status gives you prices, score changes and match transitions on one connection.

Reading an update

An odds message looks like this. The type is one of created, updated, deleted or no_markets, and every one carries a globally increasing seq.

{
  "type": "updated",
  "seq": 482917,
  "timestamp": 1723992773,
  "id": "63017989",
  "bookie": "SingBet",
  "url": "https://www.singbet.com/sports/football/match/63017989",
  "markets": [
    {
      "name": "ML",
      "updatedAt": "2024-01-15T10:30:00Z",
      "odds": [{"home": "1.85", "draw": "3.25", "away": "2.10", "max": 500}]
    }
  ]
}

Notice what is not there: no team names, no league, no kickoff. The socket carries the base event id and the bookmaker, and that is deliberate, because it keeps the message small when the same id is updated hundreds of times in a match. Join it against an event snapshot you fetched from REST at connection time and keep that map warm. Teams that skip this step end up concluding a bookmaker is unmatched when the message simply never contained a name.

deleted and no_markets always arrive with an empty markets array, even when you filtered on markets, because they describe the event, not a market. no_markets means the bookmaker has no active markets on the event at all, not that none of your subscribed markets matched.

Surviving a reconnect

Connections drop. A phone hops networks, a deploy restarts a worker, a load balancer recycles. The stream is built so a drop does not mean a rebuild.

Persist the last seq you processed. When you reconnect, pass it as lastSeq and the server replays what you missed before resuming live. The replay is compacted to the latest state per event and bookmaker, so you get the current price, not every intermediate tick. If the gap is too large to serve safely, the server pushes current state as created messages and carries on. If it cannot guarantee a complete state, it sends resync_required, and then you rebuild from REST: fetch /odds or /odds/multi with includeSeq=true, read the X-OddsAPI-Seq response header, and reconnect with that value as lastSeq.

import WebSocket from "ws";
import fs from "node:fs";

const KEY = process.env.ODDS_API_KEY;
const SEQ_FILE = "./last_seq";
let lastSeq = fs.existsSync(SEQ_FILE) ? fs.readFileSync(SEQ_FILE, "utf8").trim() : "";

function connect() {
  const url = new URL("wss://api.odds-api.io/v3/ws");
  url.searchParams.set("apiKey", KEY);
  url.searchParams.set("markets", "ML,Spread,Totals");
  url.searchParams.set("sport", "football");
  url.searchParams.set("status", "live");
  if (lastSeq) url.searchParams.set("lastSeq", lastSeq);

  const ws = new WebSocket(url);
  ws.on("message", (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === "resync_required") {
      // Complete state cannot be guaranteed: rebuild from REST, drop the stored seq.
      lastSeq = "";
      fs.rmSync(SEQ_FILE, { force: true });
      rebuildFromRest().then(() => ws.close());
      return;
    }
    if (msg.seq) {
      lastSeq = String(msg.seq);
      fs.writeFileSync(SEQ_FILE, lastSeq);
    }
    handle(msg);
  });
  ws.on("close", () => setTimeout(connect, 1000));
  ws.on("error", () => ws.close());
}

connect();

One trap: if you keep reconnecting with the same lastSeq you were already served a snapshot for, the server answers with resync_required and a snapshot_cooldown reason and replays nothing. Treat it like any other resync, rebuild from REST, and do not adopt the current sequence number the message reports.

Scores and the clock on the same connection

For a live product you almost always want the score next to the price. Add the scores and status channels and you receive score messages when a goal, point or set lands, and status messages when a match goes live, settles or is cancelled. Both carry the same clock snapshot as /events/live when the feed has one, so each real event re-anchors your local ticker.

{
  "type": "score",
  "timestamp": 1723992773,
  "id": "63017989",
  "scores": {"home": 2, "away": 1, "periods": {"p1": {"home": 1, "away": 0}}},
  "clock": {"minute": 67, "playedSeconds": 4023, "period": 2, "running": true, "statusDetail": "2nd half"}
}

The sport, leagues, eventIds and status filters apply to score and status messages as well as odds, so one URL shapes the whole feed. If you only need a scoreboard, channels=scores,status runs with no odds traffic at all.

Which one to use

Poll /odds or /odds/multi when you follow a handful of matches, a refresh of ten seconds or more is acceptable, and you want the least code. Use /odds/updated when you follow a few bookmakers closely and want to stop paying for unchanged snapshots. Move to the WebSocket when you follow many events across many books, when latency is part of the product, or when you need scores and status in the same pipeline. The stream is a paid add-on, and for a live comparison, an arbitrage scanner or an alerting product it replaces a lot of polling infrastructure.

Whichever you pick, keep one habit: fetch a REST snapshot first and treat the live feed as a diff against it. That is what makes reconnects boring, and boring is what you want at 14:59 on a Saturday.

Getting started

Create a key, list live matches with /events/live, pull a snapshot with /odds/multi, then open the socket with status=live and the markets you care about. The WebSocket reference in the docs covers every message type and the replay rules in detail, and the live esports scores guide on this blog walks through the scores and status channels with a full client.

OddsNotifier and OddsHub run on the same real-time feed you get with the API.

See what's built with Odds API →