Skip to Content
API ReferenceSSE Stream

Unified Stream

GET /api/v1/stream — Real-time odds and opportunity updates via Server-Sent Events (SSE).

Requires WebSocket Add-on ($99/mo) on any paid tier, or Enterprise (included). Free tier does not support streaming.

AuthenticationPermalink for this section

Pass your API key via header or query parameter:

# Header (recommended for server-side) curl -H "X-API-Key: sk_live_your_key" \ https://api.sharpapi.io/api/v1/stream # Query param (required for browser EventSource) https://api.sharpapi.io/api/v1/stream?api_key=sk_live_your_key

Query ParametersPermalink for this section

ParameterTypeDefaultDescription
channelstringopportunitiesWhat to stream: odds, opportunities, gamestate (Enterprise only), or all. Also accepts the plural alias channels. A comma-separated list of more than one channel collapses to all — see the note below.
sportstringallFilter by sport(s), comma-separated (e.g. basketball, football, ice_hockey)
sportsbookstringtier-allowedFilter by sportsbook(s), comma-separated
leaguestringallFilter by league(s), comma-separated
eventstringallFilter by event ID(s), comma-separated
marketstringallFilter by market type(s), comma-separated (e.g. moneyline, point_spread, total_points, player_points). Also accepted as market_type=.
min_evnumber2.0Minimum EV percentage for +EV opportunity events
min_profitnumber0.5Minimum profit percentage for arbitrage events only (does not apply to low-hold filtering)
statestringUS state code for sportsbook deep links in odds and opportunity events (e.g., nj, ny, il). Ensures deep_link URLs redirect to the correct state-specific sportsbook domain.
api_keystringAPI key (alternative to header auth for browser EventSource)

Channel OptionsPermalink for this section

ChannelEvents DeliveredUse Case
oddssnapshot, odds:update, odds:locked, odds:removed, heartbeatTrack odds movements
opportunitiessnapshot, ev:detected/expired, arb:detected/expired, middles:detected/expired, low_hold:detected/expired, heartbeatAlert on opportunities
gamestategamestate:snapshot, gamestate:update, heartbeatLive scores, periods, clocks, and situational data per event. Each gamestate:update re-emits the full current slate — there is no gamestate:removed on SSE (see gamestate:update). Enterprise tier only. See Live Game State for the full field catalog.
allAll event typesFull real-time picture

One subscription per SSE stream. For parity with the WebSocket API, the endpoint accepts both channel and the plural channels, and either one tolerates a comma-separated value. But an SSE connection carries a single subscription: if you pass more than one valid channel (e.g. ?channels=odds,opportunities), the request collapses to channel=all instead of returning an error. A single value (?channel=odds) streams just that channel. To subscribe to a specific subset of channels selectively, use the WebSocket API, which supports true multi-channel channels= filtering on one connection.

Convenience RoutesPermalink for this section

RouteEquivalent To
GET /api/v1/stream/odds/api/v1/stream?channel=odds
GET /api/v1/stream/opportunities/api/v1/stream?channel=opportunities
GET /api/v1/stream/gamestate/api/v1/stream?channel=gamestate
GET /api/v1/stream/all/api/v1/stream?channel=all
GET /api/v1/stream/events/:eventId/api/v1/stream?channel=odds&event=:eventId

SSE Event TypesPermalink for this section

connectedPermalink for this section

Sent immediately when the stream is established.

event: connected data: {"stream_id":"stream_1704960637000","channel":"all","filters":{"sportsbook":null,"sport":["basketball"],"league":["nba"],"event":null,"market":null},"reconnected":false}
FieldTypeDescription
stream_idstringUnique stream identifier
channelstringEcho of requested channel (odds, opportunities, or all)
filtersobjectEcho of active filters
resumeobjectSelf-describing resume contract: { durable, enabled, scope, resumable_channels }. Sent on every connect so your client learns the guarantees up front — see the Streaming Reliability Contract
reconnectedbooleantrue if this is a reconnection via Last-Event-ID (also echoed back as last_event_id)
resumedbooleanPresent when you reconnected with a Last-Event-ID. true → missed odds events are replayed (with replayed_count); no snapshot follows. false → the server could not resume; a full snapshot follows and fallback_reason says why
replayed_countnumberOn resumed: true — how many buffered events are replayed
fallback_reasonstringOn resumed: false — why the resume fell back (e.g. seq_too_old, process_restarted, filter_changed, channel_unsupported). Informational; the recovery is the same: accept the full snapshot. Full value list
trialobject | undefinedPresent if user is on a streaming trial. Contains active, expires_at, remaining_hours, max_streams

snapshotPermalink for this section

Full data dump sent after connected. Contains all current odds or opportunities matching your filters. Large datasets are chunked across multiple snapshot events (up to 1000 items each).

Each odds object in the snapshot contains all fields — this is the full Odds shape that your client should store locally. Subsequent odds:update events send only changed fields (see below).

event: snapshot data: {"odds":[{"id":"123456","sportsbook":"draftkings","event_id":"nba_phosuns_phi76ers_2026-02-08","sport":"basketball","league":"nba","home_team":"PHI 76ers","away_team":"PHO Suns","market_type":"moneyline","selection":"PHO Suns","selection_type":"away","odds_american":-155,"odds_decimal":1.645,"odds_probability":0.608,"line":null,"event_start_time":"2026-02-08T19:00:00Z","is_live":false,"timestamp":"2026-02-08T18:47:20Z","deep_link":"https://sportsbook.draftkings.com/event/..."}],"count":1000,"total":3200,"offset":0,"has_more":true}
FieldTypeDescription
oddsarrayArray of full Odds objects (see Odds endpoint for all fields)
countnumberNumber of odds in this chunk
totalnumberTotal number of odds matching filters
offsetnumberOffset of this chunk in the full result
has_morebooleantrue if more snapshot chunks follow

snapshot:completePermalink for this section

Signals the initial data phase is done and live deltas follow. Safe to hide loading states after receiving this. On a reconnect, this event also labels the recovery outcome — your client must branch on mode:

event: snapshot:complete data: {"timestamp":"2026-02-08T18:47:20.123Z"}
modeWhenClient action
(absent)Fresh connectNone — baseline loaded
"full_resync"A reconnect could not resume; the full snapshot you just received replaces everything (fallback_reason included)Discard prior local state — treat the snapshot as the new authoritative baseline
"resume"A reconnect successfully replayed missed events (replayed_count included; no snapshot was sent)Nothing — your state is continuous
event: snapshot:complete data: {"timestamp":"2026-02-08T18:47:20.123Z","mode":"full_resync","fallback_reason":"seq_too_old"}

See the Streaming Reliability Contract for the full recovery semantics.

odds:updatePermalink for this section

Fired when odds change for a sportsbook. Only sent on odds or all channels.

Compact delta payload. Delta events contain only fields that can change between updates — id, odds_american, odds_decimal, odds_probability, line, is_live, and timestamp. Static fields like sportsbook, sport, league, home_team, away_team, market_type, market_name, selection, deep_link, and event_start_time are not included in deltas. Merge each delta into your local odds map by id using the full objects received in the initial snapshot. See Migration: Compact SSE Deltas below.

event: odds:update id: 3f9c2a1b:12847 data: {"odds":[{"id":"123456","odds_american":-150,"odds_decimal":1.667,"odds_probability":0.6,"line":null,"is_live":false,"timestamp":"2026-02-08T18:47:38Z"}],"count":1,"book":"draftkings","partial":false}

Delta object fields (OddsDelta):

FieldTypeDescription
idstringUnique odds ID — matches the id from the initial snapshot
odds_americannumberUpdated American odds (e.g. -150)
odds_decimalnumberUpdated decimal odds (e.g. 1.667)
odds_probabilitynumberUpdated implied probability (e.g. 0.6)
linenumber | nullUpdated line/spread (e.g. -3.5), or null for moneyline
is_livebooleanWhether the event is currently live
is_activebooleantrue = market open/bettable; false = market suspended/closed with the price frozen. A market suspending (e.g. after a goal) emits an odds:update with is_active: false — grey out the line rather than trusting the frozen price. See also the odds:locked event.
is_main_linebooleantrue when this line is the consensus main line for its market; false for alternate lines. Can flip as the main line moves.
is_alternate_linebooleanPositive-polarity sibling of is_main_line (mutually exclusive).
is_stale_pregame_pricebooleantrue when a live row still carries a pre-game price that hasn’t moved since kickoff.
timestampstringISO 8601 time SharpAPI last refreshed this odd through its pipeline — advances every ingest cycle. A feed-freshness / liveness signal (matches OpticOdds’ timestamp); it is NOT when the price last changed. See understanding the timestamp field.

Exchange books additionally carry the dynamic volume, volume_24h, open_interest, and max_bet fields when present. Everything else — sportsbook, sport, league, home_team, away_team, market_type, market_name, selection, deep_link, event_start_time, and the nested entity refs — is static and comes from the initial snapshot; merge each delta into your local map by id and never read a static field off a delta.

Envelope fields:

FieldTypeDescription
oddsarrayArray of OddsDelta objects (compact — dynamic fields only)
countnumberNumber of odds in this chunk
bookstringSportsbook that changed (e.g. "draftkings")
partialbooleantrue if more chunks follow for this update batch

ev:detectedPermalink for this section

A new positive expected value opportunity has been found. Only sent on opportunities or all channels.

event: ev:detected data: [{"id":"a1b2c3d4e5f6","game_id":"nba_phosuns_phi76ers_2026-02-08","ev_percentage":4.35,"odds_american":-105,"odds_decimal":1.952,"no_vig_odds":-101,"selection":"PHO Suns -3.5","market":"point_spread","line":-3.5,"sportsbook":"draftkings","game":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","home_team":"PHI 76ers","away_team":"PHO Suns","start_time":"2026-02-08T19:00:00.000Z","is_live":false,"confidence_score":72,"kelly_percent":3.8,"book_count":4,"detected_at":"2026-02-08T18:47:20.000Z"}]

ev:expiredPermalink for this section

A previously detected +EV opportunity is no longer available.

event: ev:expired data: {"expired":["a1b2c3d4e5f6"],"timestamp":"2026-02-08T18:47:25.000Z"}

arb:detectedPermalink for this section

A new arbitrage opportunity has been found. Only sent on opportunities or all channels.

event: arb:detected data: [{"id":"61c501b83ce932d1","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"moneyline","line":null,"profit_percent":2.8,"implied_total":97.2,"is_live":false,"legs":[{"sportsbook":"draftkings","selection":"PHO Suns","odds_american":150,"odds_decimal":2.5,"implied_probability":0.4,"stake_percent":41.4},{"sportsbook":"fanduel","selection":"PHI 76ers","odds_american":-130,"odds_decimal":1.769,"implied_probability":0.5652,"stake_percent":58.6}],"detected_at":"2026-02-08T18:47:21.000Z"}]

arb:expiredPermalink for this section

A previously detected arbitrage opportunity is no longer available.

event: arb:expired data: {"expired":["nba_celtics_lakers_2026-02-08_b3:moneyline:opp_a1b2c3"],"timestamp":"2026-01-26T02:10:39.500Z"}

middles:detectedPermalink for this section

A new middle opportunity has been found. Only sent on opportunities or all channels.

event: middles:detected data: [{"id":"middle_abc123","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"player_points","side1":{"book":"draftkings","selection":"Over 22.5","line":22.5,"odds":{"american":-110,"decimal":1.909,"probability":0.5238,"fair_probability":0.51},"stake_percent":50,"odds_age_seconds":2.1,"deep_link":null},"side2":{"book":"fanduel","selection":"Under 23.5","line":23.5,"odds":{"american":-105,"decimal":1.952,"probability":0.5122,"fair_probability":0.49},"stake_percent":50,"odds_age_seconds":1.5,"deep_link":null},"middle_size":1,"middle_numbers":[23],"middle_probability":0.12,"expected_value":3.5,"quality_score":85,"detected_at":"2026-02-08T18:47:22.000Z"}]

middles:expiredPermalink for this section

A previously detected middle opportunity is no longer available.

event: middles:expired data: {"expired":["middle_abc123"]}

low_hold:detectedPermalink for this section

A new low-hold opportunity has been found. Only sent on opportunities or all channels.

event: low_hold:detected data: [{"id":"lowhold_abc123","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"moneyline","line":null,"home_team":"PHI 76ers","away_team":"PHO Suns","start_time":"2026-02-08T19:00:00.000Z","hold_percentage":1.2,"is_live":false,"all_books":["draftkings","fanduel"],"side1":{"selection":"PHO Suns","books":["draftkings"],"line":null,"odds":{"american":-108,"decimal":1.926,"implied_probability":0.5192,"fair_probability":0.5096},"deep_links":{"draftkings":"https://sportsbook.draftkings.com/event/..."}},"side2":{"selection":"PHI 76ers","books":["fanduel"],"line":null,"odds":{"american":110,"decimal":2.1,"implied_probability":0.4762,"fair_probability":0.4904},"deep_links":{"fanduel":"https://sportsbook.fanduel.com/event/..."}},"detected_at":"2026-02-08T18:47:22.000Z"}]

low_hold:expiredPermalink for this section

A previously detected low-hold opportunity is no longer available.

event: low_hold:expired data: {"expired":["lowhold_abc123"]}

gamestate:snapshotPermalink for this section

Full current live slate, sent once after connected on the gamestate (or all) channel. The payload is a flat list of event rows — each row has the same shape as a Live Game State REST event, plus its event_id.

event: gamestate:snapshot data: {"data":[{"event_id":"atp_jdoe_rroe_2026-07-03","home_team":"J. Doe","away_team":"R. Roe","sport":"tennis","league":"atp","home_score":9,"away_score":10,"game_period":"S3","is_live":true,"primary_book":"draftkings","book_count":4}]}

gamestate:updatePermalink for this section

Fired on each gamestate update cycle. Only sent on gamestate or all channels.

Full-slate re-emit — not a delta. Unlike odds:update, every SSE gamestate:update carries the complete current live slate matching your filters. There is no gamestate:removed event on SSE — an event that ends simply stops appearing in the next payload. Replace your local gamestate wholesale on every gamestate:update; don’t merge it as a delta, or ended events will linger in your state forever. One edge case: with sport/league filters set, a cycle matching zero events sends no gamestate:update at all (only unfiltered streams get the empty {"data": []} payload), so the final ended event is never replaced away — if heartbeats continue but updates stop, treat the slate as possibly empty and expire unrefreshed rows. If you need incremental delivery — changed-row updates plus explicit gamestate:removed id lists — use the WebSocket API instead; see Delivery semantics: WebSocket vs SSE.

event: gamestate:update data: {"data":[{"event_id":"atp_jdoe_rroe_2026-07-03","home_team":"J. Doe","away_team":"R. Roe","sport":"tennis","league":"atp","home_score":10,"away_score":10,"game_period":"S3","is_live":true,"primary_book":"draftkings","book_count":4}]}

The gamestate channel is also not resumable over SSE: Last-Event-ID replay covers the odds channel only. A gamestate reconnect always re-bootstraps with a fresh gamestate:snapshot — treat every reconnect as a fresh start: clear local gamestate and rebuild from the new snapshot. Note that gamestate frames (and heartbeats) carry no SSE id: line, so a plain EventSource on the gamestate channel reconnects without a Last-Event-ID — its connected event carries neither resumed nor fallback_reason. Those keys appear only when the reconnect does present a Last-Event-ID (e.g. on the all channel, where odds events set the resume cursor, or a hand-set header): the server then acknowledges with resumed: false and fallback_reason: "channel_unsupported".

odds:lockedPermalink for this section

Fired when a market is suspended/closed (e.g. after a goal, during a line move, or a late-game lockout) — the price is frozen but the selection is no longer bettable. Carries the suspended subset of the current delta, same payload shape as odds:update, with is_active: false. Only sent on odds or all channels.

This is a 1:1 analogue of OpticOdds’ locked-odds for easy migration. It is supplementary — the same rows also arrive in odds:update with is_active: false, so clients that already read is_active need not subscribe to odds:locked separately. Use it when you want a dedicated lock signal without parsing every odds:update.

event: odds:locked id: 3f9c2a1b:12851 data: {"odds":[{"id":"123456","odds_american":-2500,"is_live":true,"is_active":false,"timestamp":"2026-02-08T18:47:38Z"}],"count":1,"book":"pinnacle","partial":false}

A market re-opening emits a normal odds:update with is_active: true (and a fresh price). Markets a book removes entirely come through odds:removed instead.

odds:removedPermalink for this section

Odds removed by a sportsbook (e.g. market taken down, event settled). Only sent on odds or all channels.

event: odds:removed id: 3f9c2a1b:12850 data: {"ids":["123456","789012"],"count":2,"book":"draftkings"}
FieldTypeDescription
idsstring[]Odds IDs to remove from local state
countnumberNumber of removed odds
bookstringSportsbook that removed the odds

heartbeatPermalink for this section

Keep-alive sent every 30 seconds. If you do not receive a heartbeat within 60 seconds, the connection is stale — reconnect.

event: heartbeat data: {"type":"heartbeat","timestamp":"2026-01-26T02:11:07.846Z","server":"sharp-api-go","seq":12847,"global_seq":"12847","book_updated_ms":{"draftkings":1769393467000,"pinnacle":1769393465500}}

The heartbeat doubles as a freshness watchdog: it stamps the current data sequence (seq as a number, global_seq as a JS-safe string) and per-book last-emission clocks (book_updated_ms). Heartbeats arriving while global_seq stays flat during live games means your subscription is frozen — reconnect. A single book’s book_updated_ms going stale while others advance means that book has gone quiet. Heartbeats carry no id: line and are never replayed.

resync_requiredPermalink for this section

Sent when live deltas were skipped because your client consumed too slowly (see the slow-consumer policy). Delivered as soon as backpressure clears, before the next data event — your local state is now incomplete. Refetch /api/v1/odds for your filter scope, or reconnect for a fresh snapshot.

event: resync_required data: {"reason":"backpressure","message":"Deltas were dropped due to slow consumption. Request /api/v1/odds for a full snapshot or reconnect."}

displacedPermalink for this section

Sent as the final event when a newer connection on the same API key takes the stream slot (newer-wins — see One Connection, Many Topics); the stream then closes. reconnect: false is load-bearing: do not auto-reconnect, or you will kick your own newer session in a loop.

event: displaced data: {"code":"too_many_streams","message":"Displaced by a newer connection on the same API key (newer-wins). This stream was closed because another session took the single per-key stream slot.","reconnect":false,"hint":"Run one stream per key. To stream from multiple processes concurrently, request a maxStreams increase for your key or mint a separate key per process at https://sharpapi.io/dashboard.","docs":"https://docs.sharpapi.io/streaming/single-connection"}

errorPermalink for this section

Recoverable errors keep the connection open:

event: error data: {"code":"upstream_error","message":"Temporary issue fetching DraftKings data. Will retry."}

One class of error is terminal: streaming connections are periodically re-authorized while open. If your key loses its entitlements mid-stream (downgrade, revoked key, add-on removed), the server sends an error with code tier_restricted or invalid_api_key and then closes the stream. Reconnect to re-authorize with your current entitlements — see the reliability contract.

event: error data: {"code":"tier_restricted","message":"Streaming access was removed for this key — reconnect to re-authorize."}

ReconnectionPermalink for this section

SSE reconnects automatically (EventSource re-sends the Last-Event-ID header for you; the server hints retry: 3000). On reconnect the server resolves one of two outcomes — both explicit, never silent:

  1. Resume (connected ack: resumed: true) — the missed odds events are replayed from a best-effort window covering roughly the last 5 minutes; no snapshot is sent. Keep your local state; the replayed deltas bring it current. A snapshot:complete with mode: "resume" marks the transition to live data.
  2. Full re-sync (connected ack: resumed: false + fallback_reason) — the server could not replay (disconnected too long, reconnect landed across server maintenance, filters changed, or a non-resumable channel). A fresh full snapshot follows, and its snapshot:complete is labelled mode: "full_resync". Clear your local state before applying it — stale odds from the previous session must not mix with the new baseline.

Only odds:update, odds:locked, and odds:removed events carry SSE id: lines — the resume cursor is odds-only on SSE (gamestate/opportunities/all reconnects always take the full-re-sync path, with fallback_reason: "channel_unsupported" for the non-odds subscription). Treat the id value as an opaque cursor: echo it, never parse it.

// Browsers handle this automatically with EventSource. // For custom clients, echo the last id: value you received: const headers = { 'X-API-Key': 'YOUR_KEY', 'Last-Event-ID': '3f9c2a1b:12847' // opaque — echo verbatim };

Clear local state only on the full-re-sync path (resumed: false), not on every reconnect. After a successful resume no snapshot follows — clearing there would leave your client with nothing to merge deltas into. The reliability contract covers both paths in detail.

Resume is a best-effort optimization, not a durable log — reconnects across server maintenance or longer outages re-baseline with a full snapshot, and the server always tells you which path you got. Design for the full snapshot as the routine recovery path. The connected ack’s resume descriptor states these guarantees machine-readably on every connect.

Code ExamplesPermalink for this section

// Local odds map — keyed by odds ID, stores full Odds objects from snapshot. // Delta events merge into this map by ID. const oddsMap = new Map(); const eventSource = new EventSource( 'https://api.sharpapi.io/api/v1/stream?channel=all&league=nba&api_key=YOUR_KEY' ); eventSource.addEventListener('connected', (e) => { const { stream_id, channel, resumed } = JSON.parse(e.data); // resumed === false: resume fell back, a full snapshot follows — clear now. // resumed === true: missed deltas are replayed instead — keep local state. if (resumed === false) oddsMap.clear(); console.log(`Stream ${stream_id} connected (${channel})`); }); eventSource.addEventListener('snapshot', (e) => { const { odds, count, total, has_more } = JSON.parse(e.data); // Store full Odds objects keyed by ID for (const odd of odds) { oddsMap.set(odd.id, odd); } console.log(`Snapshot chunk: ${count} odds (${oddsMap.size}/${total} total)`); }); eventSource.addEventListener('snapshot:complete', (e) => { console.log(`Snapshot complete: ${oddsMap.size} odds loaded`); }); eventSource.addEventListener('odds:update', (e) => { const { odds, book } = JSON.parse(e.data); // Merge compact deltas into local state — only dynamic fields are sent for (const delta of odds) { const existing = oddsMap.get(delta.id); if (existing) { Object.assign(existing, delta); // Merge changed fields } // If no existing entry, the odds appeared after our snapshot — wait for next snapshot } console.log(`${book}: ${odds.length} odds updated`); }); eventSource.addEventListener('odds:removed', (e) => { const { ids, book } = JSON.parse(e.data); for (const id of ids) { oddsMap.delete(id); } console.log(`${book}: ${ids.length} odds removed`); }); eventSource.addEventListener('ev:detected', (e) => { const opps = JSON.parse(e.data); opps.forEach(opp => console.log(`+EV: ${opp.selection} at ${opp.ev_percentage}%`)); }); eventSource.addEventListener('arb:detected', (e) => { const arbs = JSON.parse(e.data); arbs.forEach(arb => console.log(`Arb: ${arb.profit_percent}% profit`)); }); eventSource.addEventListener('middles:detected', (e) => { const middles = JSON.parse(e.data); middles.forEach(m => console.log(`Middle: ${m.event_name} — EV ${m.expected_value}%`)); }); eventSource.addEventListener('low_hold:detected', (e) => { const holds = JSON.parse(e.data); holds.forEach(h => console.log(`Low hold: ${h.hold_percentage}%`)); }); eventSource.addEventListener('heartbeat', () => { console.log('Connection alive'); }); eventSource.onerror = () => { console.log('Connection lost, auto-reconnecting...'); };

Concurrent Stream LimitsPermalink for this section

Each open SSE connection counts as one stream against your limit.

PlanMax Concurrent Streams
WebSocket Add-on ($99/mo)10
EnterpriseCustom

Exceeding your stream limit returns a 429 error with code too_many_streams. Close unused streams before opening new ones.

Managing StreamsPermalink for this section

  • Each unique GET /api/v1/stream connection counts as one stream
  • Closing the HTTP connection (or calling eventSource.close()) frees the slot immediately
  • Use broader filters on fewer streams rather than many narrow streams
  • The connected event payload includes your stream_id for tracking

Error HandlingPermalink for this section

Stream-Level ErrorsPermalink for this section

Errors sent as SSE events are recoverable — the connection stays open:

event: error data: {"code":"upstream_error","message":"Temporary issue fetching data. Will retry."}

Connection-Level ErrorsPermalink for this section

These close the connection. Handle them in onerror:

Error CodeHTTP StatusDescriptionResolution
too_many_streams429Too many concurrent streamsClose unused streams
tier_restricted403Streaming not available on your tierAdd WebSocket add-on
invalid_api_key401API key missing or invalidCheck your API key
validation_error400Invalid filter parametersCheck query params

Best PracticesPermalink for this section

  1. Use the right channelchannel=odds for odds only, channel=opportunities for opportunities only, channel=all for everything
  2. Use filters to reduce bandwidth — Pass sport, league, sportsbook, market, and event params to narrow data
  3. Set thresholds — Use min_ev and min_profit to filter out low-value opportunities server-side
  4. Wait for snapshot:complete — This signals all initial data has been sent. Hide loading states after receiving it
  5. Handle odds:removed — Remove odds from local state when received to avoid showing stale data
  6. Handle reconnection gracefullyEventSource auto-reconnects, but reset local state when you receive a new snapshot event
  7. Process updates asynchronously — Do not block the event handler; queue updates for background processing
  8. Monitor heartbeats — If no heartbeat arrives within 60 seconds, consider the connection stale and reconnect
  9. Close unused streams — Each open stream counts against your concurrent limit
  10. Use Last-Event-ID — Enables the server to replay missed events after a reconnection

Migration: Compact SSE DeltasPermalink for this section

Breaking change for SSE odds:update consumers. The odds:update event now sends compact OddsDelta objects containing only dynamic fields (id, odds_american, odds_decimal, odds_probability, line, is_live, timestamp). Static fields like sportsbook, sport, league, home_team, away_team, market_type, selection, deep_link, and event_start_time are only sent in the initial snapshot event.

Why: The previous payload sent the full Odds object on every change, producing ~170 KB/s per connection. The compact delta reduces bandwidth by ~5x, sending only the 6-7 fields that actually changed.

What to change in your client:

  1. Store snapshot odds in a local map keyed by id. The snapshot event still sends full Odds objects with all fields.

  2. Merge odds:update deltas by id instead of treating them as standalone objects. Each delta only contains the fields that can change — look up the full object in your local map and apply the update.

  3. Do not access static fields on delta objects. Fields like event_id, market_type, selection, home_team, and sportsbook are not present in deltas. Read them from your local map instead.

Before (broken — accessing fields not in delta):

eventSource.addEventListener('odds:update', (e) => { const { odds } = JSON.parse(e.data); for (const o of odds) { // ❌ o.event_id, o.market_type, o.selection are undefined in deltas console.log(`${o.event_id} ${o.market_type}: ${o.selection} → ${o.odds_american}`); } });

After (correct — merge into local state):

eventSource.addEventListener('odds:update', (e) => { const { odds } = JSON.parse(e.data); for (const delta of odds) { const full = oddsMap.get(delta.id); if (full) { Object.assign(full, delta); // Merge changed fields // ✅ full.event_id, full.market_type, full.selection are still available console.log(`${full.event_id} ${full.market_type}: ${full.selection} → ${full.odds_american}`); } } });
Last updated on