Skip to Content
API ReferenceWebSocket Stream

WebSocket Stream

wss://ws.sharpapi.io — Real-time odds and opportunity updates via WebSocket.

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

Why WebSocket?

WebSocket provides a persistent, full-duplex connection. Compared to SSE:

FeatureSSE (/api/v1/stream)WebSocket (ws.sharpapi.io)
DirectionServer → Client onlyBidirectional
ReconnectionAutomatic (Last-Event-ID)Client-managed
FiltersSet once via query paramsUpdate anytime via subscribe message
ProtocolHTTP/1.1 streamingWebSocket (RFC 6455)
Browser supportNative EventSourceNative WebSocket

Both protocols deliver the same data at the same latency. Choose WebSocket when you need to change filters without reconnecting.

Authentication

Pass your API key as a query parameter on the connection URL:

wss://ws.sharpapi.io?api_key=sk_live_your_key

You can also pass initial filters and channel subscriptions as query parameters:

wss://ws.sharpapi.io?api_key=sk_live_your_key&channels=ev,odds&sport=basketball&sportsbook=draftkings,fanduel&league=nba

Query Parameters

ParameterTypeDefaultDescription
api_keystringRequired. Your API key
channelsstringallSubscribe to specific data channels, comma-separated. Valid values: ev, arbitrage, middles, low_hold, odds. Omit to receive all tier-allowed data.
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
marketstringallFilter by market type(s), comma-separated (e.g. moneyline, point_spread, total_points, player_points)
event_idstringallFilter by specific event ID(s), comma-separated
min_evnumber2.0Minimum EV percentage for +EV opportunities
min_profitnumber0.5Minimum profit percentage for arbitrage and low-hold opportunities
min_oddsnumberFilter odds by minimum American odds value (e.g., -200)
max_oddsnumberFilter odds by maximum American odds value (e.g., 500)
statestringpaUS state code for generating sportsbook deep links in opportunity events (e.g., nj, ny, il)
resumebooleanfalseSkip initial odds snapshot on reconnection (assumes client has previous state)
from_seqintegerReplay missed events since this global sequence number. Use with resume for gap-free reconnection. See Reconnection with Replay.

Use channels to reduce payload size. Without channels, the server sends all opportunity types plus the full odds dump. If you only need low-hold data, connect with channels=low_hold to skip EV, arbitrage, middles, and raw odds entirely.

Connection Lifecycle

Client Server | | |--- WS Upgrade ?api_key=xxx&channels=ev,odds →| | | Auth + acquire stream slot |← connected ----------------------------------| Welcome (tier, features, channels) |← subscribed ---------------------------------| Filter confirmation |← opportunities_snapshot (ev) ----------------| EV opportunities |← initial (draftkings) -----------------------| Odds per sportsbook |← initial (fanduel) --------------------------| (chunked by book) |← snapshot:complete --------------------------| All initial data sent | | |← odds:update --------------------------------| Incremental odds update |← ev:detected --------------------------------| +EV opportunity found |← heartbeat ----------------------------------| Keep-alive (every 30s) | | |--- { type: "ping" } → | |← pong ---------------------------------------| | | |--- { type: "subscribe", channels, filters } →| Update channels/filters |← subscribed ---------------------------------| New subscription confirmed | | |--- close ----------------------------------→| Normal close (1000)

Message Protocol

Client → Server

subscribe — Set or update channels and filters. Sent automatically on connect if passed as query params.

{ "type": "subscribe", "channels": ["ev", "odds"], "filters": { "sports": ["basketball"], "sportsbooks": ["draftkings", "fanduel"], "leagues": ["nba"], "markets": ["moneyline", "player_points"], "eventIds": ["32825-35775-2026-02-08"], "min_ev": 3.0, "min_profit": 1.5 } }
FieldTypeDescription
channelsstring[]Optional. Data channels to subscribe to: ev, arbitrage, middles, low_hold, odds. Omit to keep current channels.
filters.sportsstring[]Optional. Filter by sport(s): basketball, football, ice_hockey, baseball, soccer, etc.
filters.sportsbooksstring[]Optional. Filter by sportsbook(s).
filters.leaguesstring[]Optional. Filter by league(s).
filters.marketsstring[]Optional. Filter by market type(s).
filters.eventIdsstring[]Optional. Filter by specific event ID(s).
filters.min_evnumberOptional. Minimum EV percentage threshold (default 2.0).
filters.min_profitnumberOptional. Minimum profit percentage for arbitrage/low-hold (default 0.5).

ping — Keepalive. Send every 25 seconds to prevent timeouts.

{ "type": "ping" }

Server → Client

connected

Sent immediately after successful authentication.

{ "type": "connected", "seq": 1, "message": "Welcome to SharpAPI real-time odds stream", "stream_id": "ws_mle3husw_ezoyvp", "tier": "pro", "features": { "ev": true, "arbitrage": true, "middles": true, "low_hold": true }, "channels": ["ev", "odds"], "global_seq": 12847, "books": { "max": -1, "allowed": null }, "timestamp": "2026-02-08T18:47:17.559Z" }
FieldTypeDescription
seqintegerPer-connection message sequence number (increments with each message)
stream_idstringUnique connection identifier
tierstringYour subscription tier
featuresobjectWhich opportunity types your tier supports
channelsstring[] | nullActive channel subscriptions, or null if receiving all tier-allowed data
global_seqintegerCurrent global event sequence number. Store this for reconnection with replay.
books.maxintegerMaximum sportsbooks allowed for your tier (-1 = unlimited)
books.allowedstring[] | nullSpecific allowed sportsbooks, or null for all

subscribed

Confirms your active channels and filters.

{ "type": "subscribed", "seq": 2, "channels": ["ev", "odds"], "sports": ["basketball"], "sportsbooks": ["draftkings", "fanduel"], "leagues": ["nba"], "markets": null, "eventIds": null, "min_ev": 3.0, "min_profit": 1.5, "timestamp": "2026-02-08T18:47:17.561Z" }

opportunities_snapshot

Snapshot of opportunities for a single channel type. Sent once per subscribed opportunity channel during the initial data load. Only includes the opportunity type you subscribed to.

{ "type": "opportunities_snapshot", "seq": 3, "ev": [ { "id": "a1b2c3d4e5f6", "game_id": "nba_indianapacers_torontoraptors_2026-02-08", "ev_percent": 4.35, "odds_american": -110, "odds_decimal": 1.909, "no_vig_odds": -101, "selection": "Tyrese Haliburton Over 22.5", "market": "player_points", "line": 22.5, "sportsbook": "draftkings", "game": "Indiana Pacers @ Toronto Raptors", "sport": "basketball", "league": "nba", "home_team": "Toronto Raptors", "away_team": "Indiana Pacers", "start_time": "2026-02-08T19:00:00.000Z", "is_live": false, "confidence_score": 72, "kelly_fraction": 0.038, "book_count": 4, "detected_at": "2026-02-08T18:47:20.000Z" } ], "timestamp": "2026-02-08T18:47:17.700Z" }

The top-level key matches the channel type: ev, arbitrage, middles, or low_hold. Each snapshot message contains only one type. Large snapshots are automatically chunked — when this happens, messages include chunk and totalChunks fields.

All opportunity fields use snake_case naming (e.g. event_id, market_type, profit_percent, detected_at). This applies consistently across all channels, message types, and protocols (REST, SSE, and WebSocket).

initial

Per-sportsbook odds snapshot. Sent once per sportsbook when the odds channel is subscribed. Requires the odds channel.

{ "type": "initial", "seq": 4, "source": "draftkings", "data": [ /* NormalizedOdds[] */ ], "count": 1500, "timestamp": "2026-02-08T18:47:17.800Z" }

Odds are chunked by sportsbook — you will receive one initial message per book. Large books may be split across multiple messages (up to 1000 odds each). If you don’t need raw odds, omit the odds channel to skip this entirely.

snapshot:complete

Signals that all initial snapshots (opportunities + odds) have been sent. Safe to hide loading states after receiving this.

{ "type": "snapshot:complete", "seq": 10, "books": ["draftkings", "fanduel", "pinnacle"], "resumed": false, "progressive": true, "timestamp": "2026-02-08T18:47:18.000Z" }
FieldTypeDescription
booksstring[]List of sportsbooks included in the initial snapshot
resumedbooleantrue if this was a resume connection (no odds resent)
progressivebooleantrue if odds were delivered progressively (chunked per book)

odds:update

Incremental odds update from a single sportsbook.

{ "type": "odds:update", "seq": 46, "source": "draftkings", "data": [ /* NormalizedOdds[] */ ], "count": 23, "timestamp": "2026-02-08T18:47:19.123Z" }

odds:removed

Odds removed by a sportsbook (e.g. market taken down, event settled).

{ "type": "odds:removed", "seq": 47, "source": "draftkings", "ids": ["odd_id_1", "odd_id_2"], "count": 2, "timestamp": "2026-02-08T18:47:19.200Z" }

ev:detected

New +EV opportunity found. Pro tier or higher only.

{ "type": "ev:detected", "seq": 48, "data": [ { "id": "a1b2c3d4e5f6", "game_id": "nba_indianapacers_torontoraptors_2026-02-08", "ev_percent": 4.35, "odds_american": -110, "odds_decimal": 1.909, "no_vig_odds": -101, "selection": "Tyrese Haliburton Over 22.5", "market": "player_points", "line": 22.5, "sportsbook": "draftkings", "game": "Indiana Pacers @ Toronto Raptors", "sport": "basketball", "league": "nba", "home_team": "Toronto Raptors", "away_team": "Indiana Pacers", "start_time": "2026-02-08T19:00:00.000Z", "is_live": false, "confidence_score": 72, "kelly_fraction": 0.038, "book_count": 4, "detected_at": "2026-02-08T18:47:20.000Z" } ], "timestamp": "2026-02-08T18:47:20.000Z" }

ev:expired

Previously detected +EV opportunity is no longer available.

{ "type": "ev:expired", "seq": 49, "data": { "expired": [ "32825-35775-2026-02-08:draftkings:Tyrese Haliburton Over 22.5" ] }, "timestamp": "2026-02-08T18:47:25.000Z" }

arb:detected

New arbitrage opportunity found. Hobby tier or higher only.

{ "type": "arb:detected", "seq": 50, "data": [ { "id": "61c501b83ce932d1", "event_id": "nba_indianapacers_torontoraptors_2026-02-08", "event_name": "Indiana Pacers @ Toronto Raptors", "sport": "basketball", "league": "nba", "market_type": "moneyline", "line": null, "profit_percent": 2.8, "implied_total": 97.2, "is_live": false, "legs": [ { "sportsbook": "draftkings", "selection": "Indiana Pacers", "odds_american": 125, "odds_decimal": 2.25, "implied_probability": 0.4444, "stake_percent": 52.8 }, { "sportsbook": "fanduel", "selection": "Toronto Raptors", "odds_american": -110, "odds_decimal": 1.909, "implied_probability": 0.5238, "stake_percent": 47.2 } ], "detected_at": "2026-02-08T18:47:21.000Z" } ], "timestamp": "2026-02-08T18:47:21.000Z" }

arb:expired

Previously detected arbitrage opportunity is no longer available.

{ "type": "arb:expired", "seq": 51, "data": { "expired": [ "32825-35775-2026-02-08:moneyline" ] }, "timestamp": "2026-02-08T18:47:26.000Z" }

middles:detected

New middle opportunity found. Requires middles channel.

{ "type": "middles:detected", "seq": 52, "data": [ { "id": "abc123", "event_id": "nba_indianapacers_torontoraptors_2026-02-08", "event_name": "Indiana Pacers @ Toronto Raptors", "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": 3.2, "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.8, "deep_link": null }, "middle_size": 1, "middle_numbers": [23], "middle_probability": 0.12, "expected_value": 3.5, "roi_percentage": 4.2, "quality_score": 85, "detected_at": "2026-02-08T18:47:22.000Z" } ], "timestamp": "2026-02-08T18:47:22.000Z" }

middles:expired

Previously detected middle opportunity is no longer available.

{ "type": "middles:expired", "seq": 53, "data": { "expired": ["abc123"] }, "timestamp": "2026-02-08T18:47:27.000Z" }

low_hold:detected

New low-hold opportunity found. Requires low_hold channel.

{ "type": "low_hold:detected", "seq": 54, "data": [ { "id": "def456", "event_id": "nba_indianapacers_torontoraptors_2026-02-08", "event_name": "Indiana Pacers @ Toronto Raptors", "sport": "basketball", "league": "nba", "market_type": "moneyline", "line": null, "home_team": "Toronto Raptors", "away_team": "Indiana Pacers", "start_time": "2026-02-08T19:00:00.000Z", "hold_percentage": 1.2, "is_live": false, "all_books": ["draftkings", "fanduel"], "side1": { "selection": "Indiana Pacers", "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": "Toronto Raptors", "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" } ], "timestamp": "2026-02-08T18:47:22.000Z" }

low_hold:expired

Previously detected low-hold opportunity is no longer available.

{ "type": "low_hold:expired", "seq": 55, "data": { "expired": ["def456"] }, "timestamp": "2026-02-08T18:47:28.000Z" }

heartbeat

Keep-alive sent every 30 seconds.

{ "type": "heartbeat", "seq": 150, "timestamp": "2026-02-08T18:48:17.559Z" }

pong

Response to a client ping.

{ "type": "pong", "seq": 151, "timestamp": "2026-02-08T18:47:42.000Z" }

error

Error notification. The connection may remain open (for non-fatal errors) or close (for auth/limit errors).

{ "type": "error", "seq": 152, "code": "unknown_type", "message": "Unknown message type: foobar" }

Close Codes

CodeMeaningResolution
1000Normal closeClient or server initiated clean close
4001Authentication failureCheck your API key
4003No streaming accessAdd WebSocket add-on ($99/mo) or upgrade to Enterprise
4029Stream limit exceededClose unused connections (max 10 concurrent)

Sequence Numbers

Every server message includes a seq field — a per-connection integer that increments with each message. The connected message also includes global_seq, a server-wide event counter.

Use these for:

  • Ordering — Verify messages arrive in order by checking seq is monotonically increasing
  • Gap detection — A gap in seq means a message was lost (e.g. due to backpressure)
  • Reconnection replay — Pass global_seq as from_seq on reconnect to replay missed events

Store global_seq from the connected message and track seq from each subsequent message. On reconnect, pass the last seen sequence as from_seq to receive missed events.

Reconnection with Replay

The server maintains a 2-minute replay buffer (up to 2000 events). For brief disconnections, you can reconnect without missing data:

wss://ws.sharpapi.io?api_key=YOUR_KEY&channels=ev,odds&resume=true&from_seq=12900
ParameterEffect
resume=trueSkips the full odds snapshot (assumes client has previous state)
from_seq=NReplays all events since global sequence N

Replayed messages include "replay": true and "global_seq": N so you can distinguish them from live events.

let lastGlobalSeq = 0; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'connected') { lastGlobalSeq = msg.global_seq; } if (msg.global_seq) { lastGlobalSeq = msg.global_seq; } if (msg.replay) { console.log('Replayed event:', msg.type); } }; // On reconnect: function reconnect() { const params = new URLSearchParams({ api_key: 'YOUR_KEY', channels: 'ev,odds', resume: 'true', from_seq: lastGlobalSeq.toString() }); ws = new WebSocket(`wss://ws.sharpapi.io?${params}`); }

The replay buffer holds events for 2 minutes (max 2000 events). If you’ve been disconnected longer, omit resume and from_seq to receive a full snapshot instead.

Code Examples

// Subscribe to EV opportunities + odds only (skip middles, low_hold, arbitrage) const ws = new WebSocket( 'wss://ws.sharpapi.io?api_key=YOUR_KEY&channels=ev,odds&sport=basketball&league=nba' ); ws.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'connected': console.log(msg.message, '| tier:', msg.tier, '| channels:', msg.channels); break; case 'subscribed': console.log('Channels:', msg.channels, '| Filters:', msg.sportsbooks, msg.leagues); break; case 'opportunities_snapshot': if (msg.ev) console.log(`EV snapshot: ${msg.ev.length} opportunities`); break; case 'initial': const books = Object.keys(msg.data); console.log(`Odds snapshot: ${books.length} books`); break; case 'snapshot:complete': console.log('All initial data received'); break; case 'odds:update': console.log(`${msg.source}: ${msg.data.length} odds updated`); break; case 'ev:detected': msg.data.forEach(ev => console.log(`+EV: ${ev.selection} at ${ev.ev_percent}%`) ); break; case 'heartbeat': break; // silent keepalive } }; ws.onclose = (event) => { console.log(`Closed: ${event.code} ${event.reason}`); }; // Send ping every 25s to keep alive setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 25000); // Update channels and filters without reconnecting function updateSubscription(channels, { sports, sportsbooks, leagues } = {}) { ws.send(JSON.stringify({ type: 'subscribe', channels, filters: { sports, sportsbooks, leagues } })); }

Concurrent Stream Limits

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

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

Exceeding your stream limit closes the connection with code 4029. Close unused connections before opening new ones.

Best Practices

  1. Use channels — Subscribe only to the data you need. channels=low_hold skips the entire odds dump and other opportunity types, reducing initial payload from megabytes to kilobytes
  2. Send pings every 25 seconds — The server sends heartbeats every 30s, but explicit pings prevent proxy/firewall timeouts
  3. Use filters — Pass sport, sportsbook, league, market, and event_id params to narrow data within your subscribed channels
  4. Set thresholds — Use min_ev and min_profit to filter out low-value opportunities at the server, reducing noise
  5. Update via subscribe — Change channels, filters, and thresholds without reconnecting
  6. Handle close codes4001 means bad key, 4003 means no streaming access, 4029 means too many connections
  7. Track sequence numbers — Store global_seq for replay on reconnection. Use resume=true&from_seq=N for gap-free recovery
  8. Implement reconnection — Unlike SSE, WebSocket does not auto-reconnect. Use exponential backoff (1s, 2s, 4s, …) with from_seq replay for brief outages
  9. Wait for snapshot:complete — This signals all initial data has been sent. Hide loading states after receiving it
  10. Handle odds:removed — Remove odds from your local state when you receive this message to avoid showing stale data
  11. Close unused connections — Each connection counts against your 10-stream limit
Last updated on