Guide
Live odds over WebSocket
A Node.js consumer for the Live WebSocket that subscribes to a channel, keeps the latest quote per outcome, replays missed updates after a reconnect and backs off when the connection drops. The protocol itself is specified in Live WebSocket.
Before you start
Live is a separate product channel from REST. A connection needs an active API key, Live access on your plan and a live-enabled bookmaker entitlement for the channel; otherwise the server rejects the connection or subscription instead of falling back to REST.
Which bookmakers your key can read depends on your plan and bookmaker entitlements; see Bookmakers for the list your key returns. No bookmaker is in verified customer coverage right now. The examples use bookmaker=tipico and football. Examples use Tipico for illustration only. Tipico ingestion is offline for a source review and is not part of current customer coverage.
The key travels in the Authorization header of the WebSocket upgrade. Browsers cannot set that header, so run this client on a server or relay the stream through your backend.
npm install ws
export BETSPREAD_API_KEY="YOUR_API_KEY"Connect and subscribe
Connect to wss://stream.betspread.io and send a subscribe message for a channel of the form odds.{sport}.{bookmaker}. After a reconnect the client adds replayFrom with the last sequence it processed, so the server can replay what was missed.
import WebSocket from "ws";
const STREAM_URL = "wss://stream.betspread.io";
const CHANNEL = "odds.football.tipico";
const MAX_BACKOFF_MS = 60_000;
// Latest quote per event, market, line and outcome.
const quotes = new Map();
let lastSequence = null;
let attempt = 0;
let stopped = false;
function connect() {
const socket = new WebSocket(STREAM_URL, {
headers: { Authorization: `Bearer ${process.env.BETSPREAD_API_KEY}` },
});
socket.on("open", () => {
attempt = 0;
const subscribe = { type: "subscribe", channel: CHANNEL };
if (lastSequence !== null) subscribe.replayFrom = lastSequence;
socket.send(JSON.stringify(subscribe));
});
socket.on("message", (raw) => handleMessage(socket, JSON.parse(raw.toString())));
socket.on("close", (code) => {
if (stopped) return;
if (code === 1008) {
console.error("API key rejected (close code 1008), not reconnecting");
return;
}
scheduleReconnect();
});
// An error is always followed by close, which schedules the reconnect.
socket.on("error", (error) => console.error("socket error:", error.message));
}Handle updates and rejections
odds.update carries the event, market, outcome, decimal odds, quote status (open, suspended, closed), observedAt and the sequence used as replay cursor. Store quotes keyed by event, market, line and outcome so a replayed update overwrites instead of duplicating.
subscription.rejected (codes such as unauthorized, api_key_scope_denied, invalid_channel or channel_limit_exceeded) will not succeed on retry, so the client stops. replay.rejected leaves the live subscription active but does not fill the gap: discard local state and load a current snapshot through the REST API, for example with the JavaScript guide.
function handleMessage(socket, message) {
switch (message.type) {
case "odds.update": {
lastSequence = message.sequence;
const { event, market, outcome } = message;
quotes.set([event.id, market.type, market.line, outcome].join("|"), message);
console.log(event.homeTeam, "vs", event.awayTeam, market.type, outcome, message.odds, message.status);
break;
}
case "subscription.rejected":
// unauthorized, api_key_scope_denied, invalid_channel, channel_limit_exceeded, ...
// Retrying the same subscription does not help: fix the key, plan or channel.
console.error("subscription rejected:", message.code, message.message);
stopped = true;
socket.close();
break;
case "replay.rejected":
// The live subscription stays active, but the gap is not filled.
resync(message.code);
break;
}
}
function resync(reason) {
quotes.clear();
lastSequence = null;
console.warn(`replay rejected (${reason}): reload a REST snapshot before trusting local state`);
}Reconnect with backoff
Close code 1008 means the API key was missing, invalid, expired, revoked or not active: reconnecting with the same key fails again. Any other close, including 1011 for a server-side failure, reconnects with exponential backoff and jitter, capped at one minute. A successful open resets the backoff.
function scheduleReconnect() {
const ceiling = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
const delay = ceiling / 2 + Math.random() * (ceiling / 2);
attempt += 1;
console.warn(`reconnecting in ${Math.round(delay)} ms`);
setTimeout(connect, delay);
}
connect();Replay limits
Replay is limited by your plan's live_replay_window_seconds: 5 minutes on Growth, 15 minutes on Pro. It is part of the Live plan and needs no separate History product. A cursor older than the window answers replay.rejected with replay_window_exceeded. Replay returns at most 100 eligible changes; larger gaps answer replay_limit_exceeded with resyncRequired: true. Replay needs only the Live scope on restricted keys. In every rejected case, reload a snapshot over REST; repeating the same cursor does not recover the gap.
Live usage is metered apart from REST: subscriptions record ws.subscribe, replayed messages ws.replay and delivered updates ws.update.
Complete script
import WebSocket from "ws";
const STREAM_URL = "wss://stream.betspread.io";
const CHANNEL = "odds.football.tipico";
const MAX_BACKOFF_MS = 60_000;
// Latest quote per event, market, line and outcome.
const quotes = new Map();
let lastSequence = null;
let attempt = 0;
let stopped = false;
function connect() {
const socket = new WebSocket(STREAM_URL, {
headers: { Authorization: `Bearer ${process.env.BETSPREAD_API_KEY}` },
});
socket.on("open", () => {
attempt = 0;
const subscribe = { type: "subscribe", channel: CHANNEL };
if (lastSequence !== null) subscribe.replayFrom = lastSequence;
socket.send(JSON.stringify(subscribe));
});
socket.on("message", (raw) => handleMessage(socket, JSON.parse(raw.toString())));
socket.on("close", (code) => {
if (stopped) return;
if (code === 1008) {
console.error("API key rejected (close code 1008), not reconnecting");
return;
}
scheduleReconnect();
});
// An error is always followed by close, which schedules the reconnect.
socket.on("error", (error) => console.error("socket error:", error.message));
}
function handleMessage(socket, message) {
switch (message.type) {
case "odds.update": {
lastSequence = message.sequence;
const { event, market, outcome } = message;
quotes.set([event.id, market.type, market.line, outcome].join("|"), message);
console.log(event.homeTeam, "vs", event.awayTeam, market.type, outcome, message.odds, message.status);
break;
}
case "subscription.rejected":
// unauthorized, api_key_scope_denied, invalid_channel, channel_limit_exceeded, ...
// Retrying the same subscription does not help: fix the key, plan or channel.
console.error("subscription rejected:", message.code, message.message);
stopped = true;
socket.close();
break;
case "replay.rejected":
// The live subscription stays active, but the gap is not filled.
resync(message.code);
break;
}
}
function resync(reason) {
quotes.clear();
lastSequence = null;
console.warn(`replay rejected (${reason}): reload a REST snapshot before trusting local state`);
}
function scheduleReconnect() {
const ceiling = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
const delay = ceiling / 2 + Math.random() * (ceiling / 2);
attempt += 1;
console.warn(`reconnecting in ${Math.round(delay)} ms`);
setTimeout(connect, delay);
}
connect();node live.mjsNext steps
- Live WebSocket: full message and close-code contract.
- Quote freshness: how old a quote may be before you stop trusting it.