SSE Streaming Guide
SSE Streaming with OpticOdds
What you'll learn: How to connect to OpticOdds' Server-Sent Events (SSE) streaming endpoints, handle real-time odds and results updates, manage reconnections, filter data efficiently, and build robust streaming consumers in Python and Node.js.
What Is SSE Streaming?
Instead of repeatedly polling the REST API for updates, OpticOdds provides Server-Sent Events (SSE) streaming endpoints that push data to you in real time. When an odd changes, a line gets suspended, or a game score updates, you receive the event within milliseconds — no wasted requests, no missed updates.
SSE is a standard HTTP protocol. You open a long-lived GET request, and the server sends events down the connection as they happen. It's simpler than WebSockets (no handshake, no bidirectional messaging) and works through proxies, load balancers, and firewalls without special configuration.
Two streaming endpoints:
| Endpoint | What it streams |
|---|---|
/stream/odds/{sport} | Real-time odds changes, suspensions, and fixture status updates |
/stream/results/{sport} | Live scores, game results, and player results |
Stream Architecture
┌─────────────┐ GET (long-lived) ┌──────────────────┐
│ Your App │ ──────────────────────────────▶ │ OpticOdds SSE │
│ (Consumer) │ ◀────────────────────────────── │ Server │
└─────────────┘ event: odds / locked-odds / └──────────────────┘
fixture-status / fixture-results
event: ping (keepalive)
event: connected (on open)
How it works:
- You open an HTTP GET request with
stream=True(Python) orEventSource(Node.js/browser) - The server sends a
connectedevent confirming the connection is live - As data changes, the server pushes
odds,locked-odds,fixture-status, orfixture-resultsevents - Periodic
pingevents keep the connection alive - If the connection drops, you reconnect and optionally resume from where you left off using
last_entry_id
Streaming Odds — /stream/odds/{sport}
/stream/odds/{sport}This is the primary endpoint for real-time odds data. It replaces polling /fixtures/odds and delivers every price movement as it happens.
URL
GET https://api.opticodds.com/api/v3/stream/odds/{sport}
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
sport | string | Yes | Sport to stream (e.g., basketball, football, politics) |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes* | Your API key (alternative to X-Api-Key header) |
sportsbook | array | Yes | Sportsbooks to include (up to 5). Repeat the param for multiple: sportsbook=DraftKings&sportsbook=FanDuel |
league | array | No | Filter by league(s). Defaults to all leagues for the sport |
fixture_id | array | No | Filter by specific fixture(s) |
market | array | No | Filter by market type(s) (e.g., Moneyline, Spread) |
is_main | string | No | Filter by main lines only (true) or alternates only (false) |
is_live | string | No | Filter to live games only (true) or pre-match only (false) |
odds_format | string | No | AMERICAN (default), DECIMAL, PROBABILITY, MALAY, HONG_KONG, INDONESIAN |
exclude_fees | string | No | true to get raw exchange prices without fee adjustments. Default: false |
last_entry_id | string | No | Resume from a specific event ID after reconnection |
include_fixture_updates | string | No | true to receive fixture status and start time changes. Default: false |
include_deep_link | string | No | true to include sportsbook deep links. Default: false |
Best Practice: OpticOdds recommends grouping up to 10 leagues per connection. If you need more, open multiple streams.
Event Types
connected — Connection Confirmed
connected — Connection ConfirmedSent immediately when your stream opens successfully.
event: connected
retry: 5000
data: ok go
The retry: 5000 tells SSE clients to wait 5 seconds before auto-reconnecting if the connection drops.
ping — Keepalive
ping — KeepaliveSent periodically to keep the connection alive and confirm the server is still streaming.
event: ping
retry: 5000
data: 2024-08-28T18:57:49Z
The timestamp tells you the server's current time — useful for detecting clock drift or stale connections.
odds — New or Updated Odds
odds — New or Updated OddsFired when an odd is posted for the first time or unsuspended with a new price. This is the core event you'll process most.
event: odds
id: 1730079534820-2
retry: 5000
data: {"data":[...], "entry_id": "1730079534820-2", "type": "odds"}
The data array contains one or more Odd objects:
{
"data": [
{
"id": "28265-17233-24-44:pinnacle:1st_half_moneyline:philadelphia_eagles",
"fixture_id": "A69FA4D64518",
"game_id": "28265-17233-24-44",
"sport": "football",
"league": "NFL",
"sportsbook": "Pinnacle",
"market": "1st Half Moneyline",
"name": "Philadelphia Eagles",
"selection": "Philadelphia Eagles",
"selection_line": "",
"is_main": true,
"is_live": false,
"price": -230,
"points": null,
"timestamp": 1730079534.7125728,
"player_id": "",
"team_id": "EDCC2866B795",
"grouping_key": "default",
"limits": { "max_stake": 1000 },
"deep_link": {
"desktop": "https://www.pinnacle.com/...",
"ios": "https://www.pinnacle.com/...",
"android": "https://www.pinnacle.com/..."
}
}
],
"entry_id": "1730079534820-2",
"type": "odds"
}Key fields on each Odd:
| Field | Description |
|---|---|
id | Unique identifier for this specific odd |
fixture_id | The fixture (game/event) this odd belongs to |
sportsbook | Which sportsbook posted this price |
market | Market type (Moneyline, Spread, Total, player props, etc.) |
name | Display name of the selection |
selection | The team/player/outcome being priced |
normalized_selection | Lowercased, underscored version for matching across sportsbooks |
is_main | Whether this is the primary line (vs. an alternate) |
is_live | Whether the game is currently in play |
price | The odds value in the requested format |
points | The line/spread value (e.g., -3.5 for a spread, 45.5 for a total) |
selection_line | over / under for totals |
timestamp | Unix timestamp of when this price was posted |
grouping_key | Groups related selections together (e.g., same player prop) |
limits | Max stake at this price (where available) |
order_book | Array of [price, size] tuples — exchange order book depth (exchanges only) |
source_ids | Native exchange identifiers for order routing (exchanges only) |
deep_link | Direct links into the sportsbook app |
locked-odds — Suspended or Removed Odds
locked-odds — Suspended or Removed OddsFired when an odd is suspended (temporarily unavailable) or taken off the board. The data format is identical to odds events — the last known price is included so you know which selection was locked.
event: locked-odds
id: 1730079527180-0
retry: 5000
data: {"data":[...], "entry_id": "1730079527180-0", "type": "locked-odds"}
Why odds get locked:
- Game is about to start or a key moment is happening (kickoff, free throw, etc.)
- Sportsbook is adjusting lines after a significant event
- Market is being pulled entirely
- Main line is shifting (see "Main Line Movement" below)
Important: When you receive a
locked-oddsevent, you should mark that selection as unavailable in your system. Do not display or trade on stale locked prices.
fixture-status — Game Status Changes
fixture-status — Game Status ChangesOnly sent if you pass include_fixture_updates=true. Fires when a fixture's status or start time changes.
Start time change:
{
"data": {
"fixture_id": "FB0D30DCDD6C",
"game_id": "88067-26534-2024-35",
"sport": "tennis",
"league": "ATP",
"old_start_date": "2024-08-28T20:20:00+00:00",
"new_start_date": "2024-08-28T20:30:00+00:00",
"old_status": null,
"new_status": null,
"fixture": {
"id": "FB0D30DCDD6C",
"home_team_display": "Miomir Kecmanovic",
"away_team_display": "Lorenzo Musetti",
"start_date": "2024-08-28T20:30:00+00:00"
},
"timestamp": 1724871720.2406485
},
"entry_id": "1724871720243-0"
}Status change (e.g., cancelled):
{
"data": {
"fixture_id": "E145B23767E0",
"sport": "tennis",
"league": "WTA",
"old_status": "unplayed",
"new_status": "cancelled",
"old_start_date": null,
"new_start_date": null,
"timestamp": 1724761586.2172334
},
"entry_id": "1724761586216-0"
}Streaming Results — /stream/results/{sport}
/stream/results/{sport}Receive live scores, game outcomes, and player performance data in real time.
URL
GET https://api.opticodds.com/api/v3/stream/results/{sport}
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes* | Your API key |
league | array | No | Filter by league(s) |
fixture_id | array | No | Filter by specific fixture(s) |
Event Types
The same connected and ping events apply. The data events are:
fixture-results — Score and Game Data
fixture-results — Score and Game Data{
"data": {
"fixture_id": "B176303C982E",
"is_live": true,
"score": { ... },
"player_results": [ ... ]
},
"entry_id": "1722523617827-0"
}The score format matches the /fixtures/results endpoint. The player_results format matches the /fixtures/player-results endpoint.
Complete Code Examples
Python — Full Streaming Consumer
import requests
from requests.exceptions import ChunkedEncodingError
import json
import sseclient # pip install sseclient-py
import time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.opticodds.com/api/v3"
def stream_odds(sport, sportsbooks, leagues=None, markets=None,
odds_format="AMERICAN", on_odds=None, on_locked=None,
on_fixture_status=None):
"""
Connect to the OpticOdds SSE odds stream with automatic reconnection.
Args:
sport: Sport to stream (e.g., "basketball", "football", "politics")
sportsbooks: List of sportsbook names
leagues: Optional list of league names
markets: Optional list of market names
odds_format: Odds format (AMERICAN, DECIMAL, PROBABILITY, etc.)
on_odds: Callback for odds events
on_locked: Callback for locked-odds events
on_fixture_status: Callback for fixture-status events
"""
last_entry_id = None
reconnect_delay = 1 # Start with 1 second
while True:
try:
params = {
"key": API_KEY,
"sportsbook": sportsbooks,
"odds_format": odds_format,
"include_fixture_updates": "true",
}
if leagues:
params["league"] = leagues
if markets:
params["market"] = markets
if last_entry_id:
params["last_entry_id"] = last_entry_id
response = requests.get(
f"{BASE_URL}/stream/odds/{sport}",
params=params,
stream=True,
)
if response.status_code != 200:
print(f"HTTP {response.status_code}: {response.text}")
time.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60)
continue
# Reset reconnect delay on successful connection
reconnect_delay = 1
client = sseclient.SSEClient(response)
for event in client.events():
if event.event == "connected":
print("Connected to stream")
elif event.event == "ping":
pass # Keepalive, no action needed
elif event.event == "odds":
data = json.loads(event.data)
last_entry_id = data.get("entry_id")
if on_odds:
on_odds(data)
elif event.event == "locked-odds":
data = json.loads(event.data)
last_entry_id = data.get("entry_id")
if on_locked:
on_locked(data)
elif event.event == "fixture-status":
data = json.loads(event.data)
last_entry_id = data.get("entry_id")
if on_fixture_status:
on_fixture_status(data)
except ChunkedEncodingError:
print("Disconnected, reconnecting...")
time.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60)
except KeyboardInterrupt:
print("Stream stopped by user")
break
except Exception as e:
print(f"Error: {e}")
time.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60)
# --- Callbacks ---
def handle_odds(data):
for odd in data["data"]:
print(f"[ODDS] {odd['sportsbook']:15s} | {odd['league']:10s} | "
f"{odd['market']:20s} | {odd['name']:30s} | {odd['price']}")
def handle_locked(data):
for odd in data["data"]:
print(f"[LOCK] {odd['sportsbook']:15s} | {odd['league']:10s} | "
f"{odd['market']:20s} | {odd['name']:30s} | SUSPENDED")
def handle_fixture_status(data):
d = data["data"]
if d.get("new_status"):
print(f"[STATUS] {d['fixture_id']} | {d.get('old_status')} -> {d['new_status']}")
if d.get("new_start_date"):
print(f"[TIME] {d['fixture_id']} | {d.get('old_start_date')} -> {d['new_start_date']}")
# --- Start Streaming ---
stream_odds(
sport="basketball",
sportsbooks=["DraftKings", "FanDuel", "Pinnacle"],
leagues=["NBA"],
markets=["Moneyline", "Spread", "Total"],
odds_format="AMERICAN",
on_odds=handle_odds,
on_locked=handle_locked,
on_fixture_status=handle_fixture_status,
)Python dependencies:
pip install requests sseclient-py
Important: Use
sseclient-py(notsseclient). Install via: https://pypi.org/project/sseclient-py/
Node.js — Full Streaming Consumer
const EventSource = require("eventsource"); // npm install eventsource
const API_KEY = "YOUR_API_KEY";
const BASE_URL = "https://api.opticodds.com/api/v3";
let lastEntryId = null;
let reconnectDelay = 1000;
function streamOdds(sport, sportsbooks, options = {}) {
const { leagues, markets, oddsFormat = "AMERICAN" } = options;
const params = new URLSearchParams();
params.append("key", API_KEY);
params.append("odds_format", oddsFormat);
params.append("include_fixture_updates", "true");
sportsbooks.forEach((sb) => params.append("sportsbook", sb));
if (leagues) leagues.forEach((lg) => params.append("league", lg));
if (markets) markets.forEach((mk) => params.append("market", mk));
if (lastEntryId) params.append("last_entry_id", lastEntryId);
const url = `${BASE_URL}/stream/odds/${sport}?${params.toString()}`;
console.log(`Connecting to: ${url}`);
const eventSource = new EventSource(url);
// Connection opened
eventSource.addEventListener("connected", (event) => {
console.log("Connected to stream");
reconnectDelay = 1000; // Reset on successful connection
});
// Keepalive
eventSource.addEventListener("ping", (event) => {
// No action needed
});
// Odds updates
eventSource.addEventListener("odds", (event) => {
const data = JSON.parse(event.data);
lastEntryId = data.entry_id;
for (const odd of data.data) {
console.log(
`[ODDS] ${odd.sportsbook.padEnd(15)} | ${odd.league.padEnd(10)} | ` +
`${odd.market.padEnd(20)} | ${odd.name.padEnd(30)} | ${odd.price}`
);
}
});
// Locked/suspended odds
eventSource.addEventListener("locked-odds", (event) => {
const data = JSON.parse(event.data);
lastEntryId = data.entry_id;
for (const odd of data.data) {
console.log(
`[LOCK] ${odd.sportsbook.padEnd(15)} | ${odd.league.padEnd(10)} | ` +
`${odd.market.padEnd(20)} | ${odd.name.padEnd(30)} | SUSPENDED`
);
}
});
// Fixture status changes
eventSource.addEventListener("fixture-status", (event) => {
const data = JSON.parse(event.data);
lastEntryId = data.entry_id;
const d = data.data;
if (d.new_status) {
console.log(`[STATUS] ${d.fixture_id} | ${d.old_status} -> ${d.new_status}`);
}
if (d.new_start_date) {
console.log(`[TIME] ${d.fixture_id} | ${d.old_start_date} -> ${d.new_start_date}`);
}
});
// Error handling with reconnection
eventSource.onerror = (error) => {
console.error("Stream error, reconnecting...");
eventSource.close();
setTimeout(() => {
streamOdds(sport, sportsbooks, options);
reconnectDelay = Math.min(reconnectDelay * 2, 60000);
}, reconnectDelay);
};
return eventSource;
}
// Start streaming
streamOdds("basketball", ["DraftKings", "FanDuel", "Pinnacle"], {
leagues: ["NBA"],
markets: ["Moneyline", "Spread", "Total"],
oddsFormat: "AMERICAN",
});Node.js dependencies:
npm install eventsource
Reconnection & Resumption
Streams will occasionally disconnect — network issues, server restarts, or idle timeouts. A robust consumer must handle this gracefully.
The last_entry_id Pattern
last_entry_id PatternEvery odds, locked-odds, and fixture-status event includes an entry_id. This is a monotonically increasing identifier. When you reconnect, pass the last entry_id you processed:
GET /stream/odds/basketball?sportsbook=DraftKings&last_entry_id=1730079534820-2
The server will replay any events you missed since that ID, then continue streaming live updates. This gives you exactly-once delivery — no gaps, no duplicates.
Implementation pattern:
last_entry_id = None # Track outside the connection loop
while True:
try:
params = {"key": API_KEY, "sportsbook": ["DraftKings"]}
if last_entry_id:
params["last_entry_id"] = last_entry_id
# Connect and process events...
for event in client.events():
data = json.loads(event.data)
last_entry_id = data.get("entry_id") # Always update
process(data)
except ChunkedEncodingError:
print("Disconnected, reconnecting with last_entry_id:", last_entry_id)
time.sleep(1)Exponential Backoff
Don't hammer the server on repeated failures. Use exponential backoff:
reconnect_delay = 1 # seconds
while True:
try:
connect_and_stream()
reconnect_delay = 1 # Reset on success
except Exception:
time.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60) # Cap at 60sMain Line Movement — Understanding is_main
is_mainOne of the most nuanced aspects of the odds stream is how main line shifts are communicated. This matters if you filter by is_main.
Case 1: Main line moves, NO alternate lines exist
The sportsbook shifts the primary line (e.g., Total moves from 5.5 to 6.5). You'll receive:
locked-oddsfor the old main line (Over 5.5, Under 5.5)oddsfor the new main line (Over 6.5, Under 6.5)
This is straightforward — the old line is locked, the new line is posted.
Case 2: Main line moves, alternate lines DO exist
The sportsbook promotes an alternate to the main line. The behavior depends on your is_main filter:
If is_main=true: You receive odds events for the new main line, but you will NOT receive locked-odds events for the old main line. The old main simply disappears from your is_main=true view.
If is_main=false: You receive odds events for the demoted line (now an alternate), but you will NOT receive locked-odds events for the promoted line.
If is_main is not set: You receive odds events for all lines with their updated is_main values. No locked-odds events are sent — because no lines were actually suspended, they just changed roles.
Recommendation: If you need a complete picture of line movements, leave
is_mainunset and track theis_mainflag on each selection in your local state.
Building a Local Odds Cache
For most applications, you'll want to maintain a local cache of the current odds state. Here's the pattern:
from collections import defaultdict
# Cache structure: {odd_id: odd_data}
odds_cache = {}
locked_set = set()
def build_cache_key(odd):
"""Build a unique key for an odd."""
return odd["id"]
def handle_odds_event(data):
"""Process an odds event — add/update odds in cache."""
for odd in data["data"]:
key = build_cache_key(odd)
odds_cache[key] = odd
locked_set.discard(key) # Unlock if previously locked
def handle_locked_event(data):
"""Process a locked-odds event — mark odds as suspended."""
for odd in data["data"]:
key = build_cache_key(odd)
locked_set.add(key)
def get_active_odds(fixture_id=None, sportsbook=None, market=None):
"""Query the local cache for active (non-locked) odds."""
results = []
for key, odd in odds_cache.items():
if key in locked_set:
continue
if fixture_id and odd["fixture_id"] != fixture_id:
continue
if sportsbook and odd["sportsbook"] != sportsbook:
continue
if market and odd["market"] != market:
continue
results.append(odd)
return results
def get_best_price(fixture_id, market, selection):
"""Find the best available price across all sportsbooks."""
active = get_active_odds(fixture_id=fixture_id, market=market)
matching = [o for o in active if o["normalized_selection"] == selection]
if not matching:
return None
return max(matching, key=lambda o: o["price"])Hydrating on Startup
When your application starts, the stream only sends updates going forward. You need to hydrate your cache with a snapshot first:
def hydrate_cache(sport, sportsbooks, leagues):
"""Pull a full snapshot to initialize the local cache."""
response = requests.get(
f"{BASE_URL}/fixtures/odds",
headers={"X-Api-Key": API_KEY},
params={
"sport": sport,
"sportsbook": sportsbooks,
"league": leagues,
"is_live": "false",
}
)
for fixture in response.json()["data"]:
for odd in fixture.get("odds", []):
key = build_cache_key(odd)
odds_cache[key] = odd
print(f"Cache hydrated with {len(odds_cache)} odds")
# 1. Hydrate cache
hydrate_cache("basketball", ["DraftKings", "FanDuel"], ["NBA"])
# 2. Start streaming (updates flow into the same cache)
stream_odds("basketball", ["DraftKings", "FanDuel"], leagues=["NBA"],
on_odds=handle_odds_event, on_locked=handle_locked_event)Recommended Data Keys
OpticOdds recommends specific key structures depending on your use case:
Tracking multiple sportsbooks per game/market:
Key = fixture_id + sportsbook + market + name
Tracking a single sportsbook per game/market:
Key = fixture_id + market + name
The id field on each Odd object already encodes this: {game_id}:{sportsbook}:{market}:{normalized_selection}. You can use it directly as your cache key.
Multi-Stream Architecture
For production systems tracking many sports and leagues, you'll need multiple concurrent streams.
Recommended Topology
Stream 1: basketball / NBA, NCAAB (DraftKings, FanDuel, Pinnacle)
Stream 2: football / NFL, NCAAF (DraftKings, FanDuel, Pinnacle)
Stream 3: baseball / MLB (DraftKings, FanDuel, Pinnacle)
Stream 4: politics / all leagues (Kalshi, Polymarket)
Stream 5: soccer / EPL, La Liga (Bet365, Pinnacle)
...
Guidelines:
- Up to 10 leagues per stream — OpticOdds' recommended limit for optimal performance
- Up to 5 sportsbooks per stream — API limit per request
- Separate streams by sport — The URL path requires a sport
- Dedicated results streams — Run
/stream/resultsas separate connections from/stream/odds - Budget your rate limits — Streaming connections count toward the 250/15s streaming rate limit
Python Multi-Stream with Threading
import threading
def start_stream(sport, sportsbooks, leagues):
"""Start a stream in a background thread."""
thread = threading.Thread(
target=stream_odds,
args=(sport, sportsbooks),
kwargs={"leagues": leagues, "on_odds": handle_odds_event,
"on_locked": handle_locked_event},
daemon=True,
)
thread.start()
return thread
# Launch multiple streams
threads = [
start_stream("basketball", ["DraftKings", "FanDuel", "Pinnacle"], ["NBA", "NCAAB"]),
start_stream("football", ["DraftKings", "FanDuel", "Pinnacle"], ["NFL"]),
start_stream("politics", ["Kalshi", "Polymarket"], None),
]
# Keep main thread alive
for t in threads:
t.join()Rate Limits
| Tier | Limit | Applies To |
|---|---|---|
| Streaming | 250 requests / 15 seconds | /stream/odds, /stream/results |
| Standard | 2,500 requests / 15 seconds | /fixtures/odds, /fixtures/active, etc. |
| Historical | 10 requests / 15 seconds | /fixtures/odds/history |
Each new SSE connection counts as one request against the streaming limit. Once connected, the long-lived connection does not consume additional rate limit. Reconnections count as new requests.
How Rate Limits Work with Streaming
- Opening a connection = 1 request against the streaming tier (250/15s)
- Maintaining a connection = free — no ongoing rate limit cost
- Reconnecting = 1 new request — this is why backoff matters
- Snapshot calls (
/fixtures/odds) use the standard tier (2,500/15s), separate from streaming - Historical pulls (
/fixtures/odds/history) have their own strict tier (10/15s)
Rate Limit Best Practices
-
Minimize connection churn — Every disconnect/reconnect costs a streaming request. Invest in stable connections: use keepalive detection via
pingevents, handle network interruptions gracefully, and don't close connections unnecessarily. -
Use exponential backoff on reconnect — If you're getting 429s or repeated failures, back off exponentially (1s → 2s → 4s → 8s → ... capped at 60s). Rapid reconnection attempts can burn through your streaming limit in seconds.
-
Consolidate streams where possible — One stream with 10 leagues is better than 10 streams with 1 league each. Each stream burns a connection. Group related leagues into the same stream (up to 10 leagues per connection is recommended).
-
Separate streaming and snapshot budgets — Streaming (250/15s) and standard (2,500/15s) are separate pools. Use
/fixtures/oddssnapshots freely for hydration and recovery without worrying about your streaming budget. -
Stagger startup connections — If your system opens 20+ streams at startup, stagger them over a few seconds to avoid spiking your streaming rate limit all at once.
-
Track your usage — Log how many connections you open per 15-second window. If you're consistently above 200/15s, restructure your stream topology to consolidate.
-
Plan historical pulls carefully — At 10 requests per 15 seconds, pulling historical data for backtesting requires patience. Queue your historical requests with a 1.5-second delay between each, and batch by fixture to minimize total requests.
-
Don't poll when you can stream — A common mistake is using
/fixtures/oddson a timer (e.g., every 5 seconds) for "real-time" data. This wastes standard tier budget and introduces latency. Switch to/stream/oddsfor anything requiring sub-minute freshness. -
Cache aggressively — Discovery endpoints (
/sports,/leagues,/sportsbooks,/markets) change infrequently. Cache their responses locally and refresh once per hour or at startup — don't pull them repeatedly. -
Handle 429 responses gracefully — If you receive HTTP 429 (Too Many Requests), stop all new connection attempts immediately, wait at least 15 seconds, then resume with backoff. Do not retry immediately — it will only extend the rate limit window.
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
No events after connected | No matching data for your filters | Verify your sportsbook, league, and market params have active fixtures |
| Frequent disconnections | Network instability or idle timeout | Implement reconnection with last_entry_id and exponential backoff |
| Missing odds updates | Using is_main filter during line shifts | Leave is_main unset for complete coverage, or track both values |
| HTTP 429 | Rate limit exceeded | Reduce connection frequency; stay under 250/15s for streaming |
| Stale data after reconnect | Not using last_entry_id | Always track and pass last_entry_id on reconnection |
| Duplicate events | Reconnecting without last_entry_id | Track entry_id values and deduplicate in your consumer |
Quick Reference
| What | Where |
|---|---|
| Stream live odds | GET /stream/odds/{sport} |
| Stream live results | GET /stream/results/{sport} |
| Resume after disconnect | Pass last_entry_id query param |
| Get fixture status changes | Set include_fixture_updates=true |
| Raw exchange prices | Set exclude_fees=true |
| Odds event | event: odds — new/updated price |
| Lock event | event: locked-odds — suspended price |
| Status event | event: fixture-status — game time/status change |
| Results event | event: fixture-results — score update |
| Ping event | event: ping — keepalive |
| Connected event | event: connected — stream opened |
| Python SSE library | pip install sseclient-py |
| Node.js SSE library | npm install eventsource |
| Max leagues per stream | 10 (recommended) |
| Max sportsbooks per request | 5 |
| Streaming rate limit | 250 connections / 15 seconds |
Questions? Contact your OpticOdds account team for help with streaming architecture or custom data requirements.
Updated about 4 hours ago