WebSocket is available on all plans. Free plan: 30 connections/day. Pro plan: not included. Pro+ RT and Pro+ Live: unlimited.
WS /api/stream
Connect once and receive odds change events pushed by the server as bookmakers update their lines. Latency is typically 1–3 seconds from scrape to push.
For a full walkthrough building a production live feed, see the WebSocket Setup Guide.
Authentication: two-step token exchange
WebSocket connections cannot carry custom headers in browsers, and long-lived connections cannot use the same auth mechanism as short-lived HTTP requests. OddsStream uses a short-lived token (5-minute TTL) obtained from the REST API:
Step 1 — Get a token:
curl -X POST "https://oddsstream.io/api/stream/token" \
-H "X-Api-Key: os_live_YOUR_KEY"
Response:
{ "token": "<short-lived-token>", "expires_in": 300 }
Step 2 — Connect with the token:
wss://api.oddsstream.io/api/stream?token=<short-lived-token>
The token expires after 5 minutes. Fetch a new one before each connection (including reconnects). If the token is missing, expired, or invalid the server closes the connection immediately with code 4001.
Why a token, not X-Api-Key directly?
- Browsers can’t set custom headers on WebSocket connections
- The WS server (Railway) is separate from the auth gateway (Next.js/Vercel) — the token is the signed credential that crosses this boundary
- Short TTL limits exposure if a token is intercepted
Connection URL
wss://api.oddsstream.io/api/stream?token=<token>
import WebSocket from "ws"; // npm install ws
async function getToken(apiKey) {
const res = await fetch("https://oddsstream.io/api/stream/token", {
method: "POST",
headers: { "X-Api-Key": apiKey },
});
if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
const { token } = await res.json();
return token;
}
const token = await getToken(process.env.ODDSSTREAM_API_KEY);
const ws = new WebSocket(`wss://api.oddsstream.io/api/stream?token=${token}`);
ws.on("open", () => console.log("Connected"));
ws.on("message", (data) => console.log(JSON.parse(data)));
ws.on("error", (err) => console.error("Error:", err.message));
ws.on("close", (code) => console.log("Disconnected:", code));
// Get the token from your own server (never expose X-Api-Key in browser code)
const { token } = await fetch("/your-backend/ws-token").then(r => r.json());
const ws = new WebSocket(`wss://api.oddsstream.io/api/stream?token=${token}`);
ws.onopen = () => console.log("Connected");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onerror = (e) => console.error("Error:", e);
ws.onclose = (e) => console.log("Disconnected:", e.code);
import asyncio, json, os, httpx, websockets
API_KEY = os.environ["ODDSSTREAM_API_KEY"]
async def get_token():
async with httpx.AsyncClient() as client:
r = await client.post(
"https://oddsstream.io/api/stream/token",
headers={"X-Api-Key": API_KEY},
)
r.raise_for_status()
return r.json()["token"]
async def main():
token = await get_token()
uri = f"wss://api.oddsstream.io/api/stream?token={token}"
async with websockets.connect(uri) as ws:
print("Connected")
async for message in ws:
print(json.loads(message))
asyncio.run(main())
Filter Parameters
Narrow what you receive by appending filters to the connection URL. Combine them freely.
| Parameter | Example | Description |
|---|
token | ?token=<token> | Required. Short-lived auth token. |
sport | &sport=Football | Only events for this sport |
competition | &competition=EPL | Only events for this competition code |
bookmaker | &bookmaker=Winamax | Only events from this bookmaker |
game_id | &game_id=<uuid> | Only changes for one specific game (UUID from GET /api/games) |
wss://api.oddsstream.io/api/stream?token=<token>&sport=Football&competition=EPL
wss://api.oddsstream.io/api/stream?token=<token>&sport=Basketball&bookmaker=Unibet.fr
Unfiltered connections receive all odds changes across all sports and bookmakers — useful for data ingestion pipelines but can deliver 50–200 messages/minute during peak hours.
Each push message is a JSON object representing one selection’s odds changing. One market update (e.g. a 3-way moneyline) produces 3 separate messages — one per selection.
{
"bookmaker": "Winamax",
"match_name": "PSG - Lyon",
"competition": "LIG1",
"sport": "Football",
"market_type": "moneyline",
"selection": "PSG",
"odds": 1.85,
"period": 0,
"is_live": false,
"scraped_at": "2026-04-20T14:30:12Z"
}
Message Fields
| Field | Type | Description |
|---|
bookmaker | string | Which bookmaker changed their price |
match_name | string | Canonical event name (e.g. "PSG - Lyon") |
competition | string | Competition code (e.g. LIG1 = Ligue 1) |
sport | string | Sport name |
market_type | string | Market type — see Market Types |
selection | string | Which outcome changed (e.g. "PSG", "Over 2.5") |
odds | float | New decimal odds value |
period | integer | 0 = full time/game, 1 = first half |
is_live | boolean | true if the match is currently in-play |
scraped_at | ISO-8601 string | UTC timestamp of the scrape that triggered this push |
Keepalive
The server drops idle connections after ~5 minutes. Send the string "ping" every 30 seconds. The server responds with "pong".
ws.on("open", () => {
const interval = setInterval(
() => ws.readyState === WebSocket.OPEN && ws.send("ping"),
30_000
);
ws.on("close", () => clearInterval(interval));
});
ws.on("message", (raw) => {
if (raw.toString() === "pong") return; // ignore keepalive responses
const update = JSON.parse(raw.toString());
// handle update
});
async def keepalive(ws):
while True:
await asyncio.sleep(30)
await ws.send("ping")
async with websockets.connect(uri) as ws:
asyncio.create_task(keepalive(ws))
async for message in ws:
if message == "pong":
continue
update = json.loads(message)
# handle update
Reconnection
The server restarts for deployments. Implement exponential backoff and re-fetch a token on each reconnect attempt.
let delay = 1000;
async function connect() {
const token = await getToken(process.env.ODDSSTREAM_API_KEY);
const ws = new WebSocket(
`wss://api.oddsstream.io/api/stream?token=${token}&sport=Football`
);
ws.on("open", () => {
delay = 1000; // reset on success
console.log("Connected");
setInterval(() => ws.send("ping"), 30_000);
});
ws.on("message", (raw) => {
if (raw.toString() === "pong") return;
handleUpdate(JSON.parse(raw.toString()));
});
ws.on("close", (code) => {
console.log(`Disconnected (${code}), reconnecting in ${delay}ms`);
setTimeout(connect, delay);
delay = Math.min(delay * 2, 30_000);
});
ws.on("error", (e) => console.error("WS error:", e.message));
}
connect();
import asyncio, json, os, httpx, websockets
from websockets.exceptions import ConnectionClosed
API_KEY = os.environ["ODDSSTREAM_API_KEY"]
async def get_token():
async with httpx.AsyncClient() as client:
r = await client.post(
"https://oddsstream.io/api/stream/token",
headers={"X-Api-Key": API_KEY},
)
r.raise_for_status()
return r.json()["token"]
async def stream():
uri_base = "wss://api.oddsstream.io/api/stream?sport=Football"
delay = 1
while True:
try:
token = await get_token()
async with websockets.connect(f"{uri_base}&token={token}") as ws:
delay = 1
print("Connected")
async def ping():
while True:
await asyncio.sleep(30)
await ws.send("ping")
asyncio.create_task(ping())
async for message in ws:
if message == "pong":
continue
print(json.loads(message))
except (ConnectionClosed, OSError) as e:
print(f"Disconnected: {e}. Retrying in {delay}s")
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(stream())
Connection lifecycle
Client Server
│ │
├── POST /api/stream/token (X-Api-Key) ──► │ Next.js auth gateway
◄── { token, expires_in: 300 } ───────── │
│ │
├── WS upgrade (?token=<token>) ────────► │ Railway WS server
◄── 101 Switching Protocols ──────────── │
│ │
◄── {"bookmaker": "Winamax", ...} ────── │ odds change
◄── {"bookmaker": "Betsson", ...} ────── │ odds change
│ │
├── "ping" ───────────────────────────► │ every 30s
◄── "pong" ────────────────────────── │
│ │
├── (close / reconnect) ─────────────► │ re-fetch token first
Close codes
| Code | Reason |
|---|
4001 | Invalid, missing, or expired token — fetch a new one before reconnecting |
Notes
- Scrape frequency varies — 5–30 seconds per bookmaker. Don’t expect sub-second latency.
- Individual selection updates — one message per changed odds line, not grouped by market. A 3-way moneyline update = 3 messages.
- WS does not consume HTTP rate limit quota — streaming is billed separately by plan.
- Token TTL — tokens expire 5 minutes after issue. Re-fetch before each new connection.
- Free plan — 30 WS connections per day. Each connection counts (even reconnects), so implement keepalive to avoid unnecessary reconnects.
- For pre-match monitoring —
GET /api/odds polled every 30 seconds is more bandwidth-efficient than WS during low-change periods.
- For live in-play monitoring — WebSocket is strongly preferred; in-play odds change every 5–10 seconds.