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:

EndpointWhat 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:

  1. You open an HTTP GET request with stream=True (Python) or EventSource (Node.js/browser)
  2. The server sends a connected event confirming the connection is live
  3. As data changes, the server pushes odds, locked-odds, fixture-status, or fixture-results events
  4. Periodic ping events keep the connection alive
  5. If the connection drops, you reconnect and optionally resume from where you left off using last_entry_id

Streaming Odds — /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

ParameterTypeRequiredDescription
sportstringYesSport to stream (e.g., basketball, football, politics)

Query Parameters

ParameterTypeRequiredDescription
keystringYes*Your API key (alternative to X-Api-Key header)
sportsbookarrayYesSportsbooks to include (up to 5). Repeat the param for multiple: sportsbook=DraftKings&sportsbook=FanDuel
leaguearrayNoFilter by league(s). Defaults to all leagues for the sport
fixture_idarrayNoFilter by specific fixture(s)
marketarrayNoFilter by market type(s) (e.g., Moneyline, Spread)
is_mainstringNoFilter by main lines only (true) or alternates only (false)
is_livestringNoFilter to live games only (true) or pre-match only (false)
odds_formatstringNoAMERICAN (default), DECIMAL, PROBABILITY, MALAY, HONG_KONG, INDONESIAN
exclude_feesstringNotrue to get raw exchange prices without fee adjustments. Default: false
last_entry_idstringNoResume from a specific event ID after reconnection
include_fixture_updatesstringNotrue to receive fixture status and start time changes. Default: false
include_deep_linkstringNotrue 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

Sent 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

Sent 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

Fired 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:

FieldDescription
idUnique identifier for this specific odd
fixture_idThe fixture (game/event) this odd belongs to
sportsbookWhich sportsbook posted this price
marketMarket type (Moneyline, Spread, Total, player props, etc.)
nameDisplay name of the selection
selectionThe team/player/outcome being priced
normalized_selectionLowercased, underscored version for matching across sportsbooks
is_mainWhether this is the primary line (vs. an alternate)
is_liveWhether the game is currently in play
priceThe odds value in the requested format
pointsThe line/spread value (e.g., -3.5 for a spread, 45.5 for a total)
selection_lineover / under for totals
timestampUnix timestamp of when this price was posted
grouping_keyGroups related selections together (e.g., same player prop)
limitsMax stake at this price (where available)
order_bookArray of [price, size] tuples — exchange order book depth (exchanges only)
source_idsNative exchange identifiers for order routing (exchanges only)
deep_linkDirect links into the sportsbook app

locked-odds — Suspended or Removed Odds

Fired 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-odds event, you should mark that selection as unavailable in your system. Do not display or trade on stale locked prices.


fixture-status — Game Status Changes

Only 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}

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

ParameterTypeRequiredDescription
keystringYes*Your API key
leaguearrayNoFilter by league(s)
fixture_idarrayNoFilter by specific fixture(s)

Event Types

The same connected and ping events apply. The data events are:

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 (not sseclient). 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

Every 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 60s

Main Line Movement — Understanding is_main

One 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:

  1. locked-odds for the old main line (Over 5.5, Under 5.5)
  2. odds for 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_main unset and track the is_main flag 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/results as 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

TierLimitApplies To
Streaming250 requests / 15 seconds/stream/odds, /stream/results
Standard2,500 requests / 15 seconds/fixtures/odds, /fixtures/active, etc.
Historical10 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

  1. Minimize connection churn — Every disconnect/reconnect costs a streaming request. Invest in stable connections: use keepalive detection via ping events, handle network interruptions gracefully, and don't close connections unnecessarily.

  2. 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.

  3. 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).

  4. Separate streaming and snapshot budgets — Streaming (250/15s) and standard (2,500/15s) are separate pools. Use /fixtures/odds snapshots freely for hydration and recovery without worrying about your streaming budget.

  5. 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.

  6. 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.

  7. 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.

  8. Don't poll when you can stream — A common mistake is using /fixtures/odds on a timer (e.g., every 5 seconds) for "real-time" data. This wastes standard tier budget and introduces latency. Switch to /stream/odds for anything requiring sub-minute freshness.

  9. 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.

  10. 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

IssueCauseFix
No events after connectedNo matching data for your filtersVerify your sportsbook, league, and market params have active fixtures
Frequent disconnectionsNetwork instability or idle timeoutImplement reconnection with last_entry_id and exponential backoff
Missing odds updatesUsing is_main filter during line shiftsLeave is_main unset for complete coverage, or track both values
HTTP 429Rate limit exceededReduce connection frequency; stay under 250/15s for streaming
Stale data after reconnectNot using last_entry_idAlways track and pass last_entry_id on reconnection
Duplicate eventsReconnecting without last_entry_idTrack entry_id values and deduplicate in your consumer

Quick Reference

WhatWhere
Stream live oddsGET /stream/odds/{sport}
Stream live resultsGET /stream/results/{sport}
Resume after disconnectPass last_entry_id query param
Get fixture status changesSet include_fixture_updates=true
Raw exchange pricesSet exclude_fees=true
Odds eventevent: odds — new/updated price
Lock eventevent: locked-odds — suspended price
Status eventevent: fixture-status — game time/status change
Results eventevent: fixture-results — score update
Ping eventevent: ping — keepalive
Connected eventevent: connected — stream opened
Python SSE librarypip install sseclient-py
Node.js SSE librarynpm install eventsource
Max leagues per stream10 (recommended)
Max sportsbooks per request5
Streaming rate limit250 connections / 15 seconds

Questions? Contact your OpticOdds account team for help with streaming architecture or custom data requirements.