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.

A machine-readable AsyncAPI 3.0 description of this endpoint — channels, messages, schemas, and bindings — is published at /asyncapi.yaml. Use it for SDK codegen or to drive AsyncAPI tooling such as Studio .

Why WebSocket?Permalink for this section

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.

AuthenticationPermalink for this section

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 ParametersPermalink for this section

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). Also accepted as market_type=.
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)
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.
from_seqstringAttempt best-effort replay after this opaque global_seq checkpoint. 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 LifecyclePermalink for this section

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 ProtocolPermalink for this section

Client → ServerPermalink for this section

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

filter — Replace the active filters without changing channels. This updates live delivery but does not send a fresh snapshot.

{ "type": "filter", "filters": { "sports": ["basketball"], "sportsbooks": ["draftkings", "fanduel"], "leagues": ["nba"] } }

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

{ "type": "ping" }

unsubscribe — Remove one or more active channels. To obtain a fresh snapshot for a channel on the same authenticated connection, unsubscribe it, optionally send a filter update, and then subscribe to the channel again.

{ "type": "unsubscribe", "channels": ["odds"] }

A duplicate subscribe for an already-active channel and a standalone filter update change live delivery but do not send a fresh snapshot. There is no client resync message; resync_required is a server-to-client recovery signal described under Full Resynchronization.

Server → ClientPermalink for this section

connectedPermalink for this section

Sent immediately after successful authentication.

{ "type": "connected", "seq": 12847, "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
seqintegerLegacy integer representation of the process-global checkpoint. Not every control or snapshot frame includes it.
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_seqstringJavaScript-safe decimal string checkpoint. Treat it as opaque and store it without numeric conversion.
resumedbooleanOn a from_seq attempt, whether replay was accepted. A decline includes fallback_reason.
fallback_reasonstringPresent when a requested resume is declined; the connection then receives a full authoritative snapshot.
books.maxintegerMaximum sportsbooks allowed for your tier (-1 = unlimited)
books.allowedstring[] | nullSpecific allowed sportsbooks, or null for all

subscribedPermalink for this section

Confirms one requested channel and the resolved filter state. The server sends one acknowledgement per entry in the subscribe.channels array.

{ "type": "subscribed", "channel": "ev", "filters": { "sport": ["basketball"], "sportsbook": ["draftkings", "fanduel"], "league": ["nba"], "min_ev": 3.0, "min_profit": 1.5 } }

unsubscribedPermalink for this section

Confirms removal of one requested channel. As with subscribed, the server sends one acknowledgement per channel in the client message.

{ "type": "unsubscribed", "channel": "odds" }

opportunities_snapshotPermalink for this section

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", "ev": [ { "id": "a1b2c3d4e5f6", "game_id": "nba_indianapacers_torontoraptors_2026-02-08", "ev_percentage": 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_percent": 3.8, "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).

initialPermalink for this section

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

{ "type": "initial", "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 (each frame is capped at 256KB serialized). If you don’t need raw odds, omit the odds channel to skip this entirely.

snapshot:completePermalink for this section

Signals the end of an initial snapshot, a full-resync fallback, or a successful replay. A full snapshot carries books and total_odds. When a requested resume is declined, it also carries mode: "full_resync" and the same fallback_reason reported by connected:

{ "type": "snapshot:complete", "books": ["draftkings", "fanduel", "pinnacle"], "total_odds": 2841, "mode": "full_resync", "fallback_reason": "seq_too_old" }

An accepted normal or coalesced replay has a different completion shape:

{ "type": "snapshot:complete", "mode": "resume", "replayed_count": 127, "skipped_count": 3, "last_seq": "12974", "gap_detected": false }

An ordinary fresh snapshot omits mode and fallback_reason. Replay acceptance is reported by the earlier connected frame, not this completion frame.

FieldTypeDescription
booksstring[]List of sportsbooks included in the initial snapshot
total_oddsintegerTotal odds rows sent in the full snapshot
modestringresume after replay, or full_resync after an explicitly declined resume. An ordinary fresh snapshot may omit it.
fallback_reasonstringWhy the requested checkpoint could not be replayed. Matches the reason in connected.
replayed_countintegerNormal replay: buffered frames sent. Coalesced replay: current changed rows sent.
skipped_countintegerBuffered frames excluded by the active subscription and filters. Coalesced replay reports 0.
last_seqstringServer-issued receipt for the completed replay walk. Persist it only after receiving this completion boundary.
gap_detectedbooleanOn a started replay, true means the client must reconcile state instead of treating completion as authoritative.

odds:updatePermalink for this section

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:lockedPermalink for this section

A market was suspended/closed (e.g. after a goal, a line move, or a late-game lockout) — the price is frozen and no longer bettable. Carries the suspended subset of the delta (same payload as odds:update, with is_active: false). A 1:1 analogue of OpticOdds’ locked-odds.

Supplementary: the same rows also arrive in odds:update with is_active: false, so clients reading is_active need not subscribe separately. A re-open emits a normal odds:update with is_active: true; a full removal comes through odds:removed.

{ "type": "odds:locked", "seq": 48, "source": "pinnacle", "data": [ /* NormalizedOdds[] with is_active: false */ ], "count": 1, "timestamp": "2026-02-08T18:47:19.250Z" }

odds:removedPermalink for this section

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:detectedPermalink for this section

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_percentage": 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_percent": 3.8, "book_count": 4, "detected_at": "2026-02-08T18:47:20.000Z" } ], "timestamp": "2026-02-08T18:47:20.000Z" }

ev:expiredPermalink for this section

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:detectedPermalink for this section

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:expiredPermalink for this section

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:detectedPermalink for this section

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:expiredPermalink for this section

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:detectedPermalink for this section

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:expiredPermalink for this section

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

heartbeatPermalink for this section

Keep-alive sent every 30 seconds.

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

pongPermalink for this section

Response to a client ping.

{ "type": "pong" }

errorPermalink for this section

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

{ "type": "error", "code": "unknown_message_type", "message": "Unknown message type: foobar" }

The WebSocket layer emits a small, fixed set of frame-level error codes for client-protocol mistakes. They are distinct from the HTTP error codes returned by REST endpoints.

CodeMeaning
invalid_messageFrame could not be parsed as JSON or did not match the expected shape
unknown_message_typetype field is not one of auth, token_refresh, subscribe, filter, ping, unsubscribe
missing_tokenauth or token_refresh frame did not include a token field
missing_channelssubscribe frame did not include a non-empty channels array
not_authenticatedSent subscribe, filter, or token_refresh before auth succeeded
already_authenticatedClient sent a second auth frame after the first one succeeded

WebSocket frames may also carry the HTTP-style invalid_api_key, tier_restricted, and too_many_streams codes — these cause the server to close the connection after the frame is sent. See API Overview → Error Codes for the full list.

Close CodesPermalink for this section

CodeMeaningResolution
1000Normal closeClient or server initiated clean close
1006Abnormal closure (client-side)Network drop or process kill — always reconnect
1009Message too big (client-side)Your library’s incoming-message cap is below the 256KB snapshot frame size — raise it (e.g. Python websockets max_size) to at least 512KB
4001Authentication failure, or displaced by a newer sessionCheck your API key. If the close reason is displaced by newer session, another connection took the single per-key slot — do not auto-reconnect
4003Entitlements changed mid-stream (downgrade, revoked key, add-on removed)Reconnect to re-authorize with current entitlements. If the plan actually changed, fix that first — a reconnect on a revoked key or removed add-on is refused at authentication, not retried

Code 1006 is RFC 6455 reserved and is never transmitted over the wire. Your WebSocket library generates it locally when the TCP connection is lost without a proper closing handshake (network failure, process kill, OS-level timeout). The server did not send it. Always reconnect on 1006.

Sequence NumbersPermalink for this section

global_seq is a JavaScript-safe decimal string checkpoint for best-effort reconnect replay. Replayable data frames may also carry seq, the same process-global checkpoint in a legacy integer representation. These values are not per-connection counters and are not contiguous for a filtered subscriber: frames for other channels, books, and subscribers consume values too.

Observed numeric gaps therefore do not indicate loss or a dropped message. React to explicit recovery signals—resync_required, gap_detected, and a declined resume—rather than requiring N+1 continuity. Snapshot and control frames do not uniformly carry either sequence field. Replayed data retains its original checkpoint and adds "replay": true.

Delivery across books can also reorder process-global values, so a client may observe 101 before 100. Never compute a reconnect checkpoint from the last value observed, a running maximum, per-source maxima, or apparent contiguity. There is no client-computable checkpoint that advances between server-issued receipts.

The safe advancing receipt is last_seq on a received snapshot:complete with mode: "resume". On a fresh connection or full-resync fallback, connected.global_seq is a conservative floor, but do not commit that floor until the following fresh/full snapshot:complete confirms the authoritative baseline was fully received. heartbeat.global_seq is unsafe as a receipt: it can reflect a value minted ahead of delivery. Preserve safe checkpoints as decimal strings without JavaScript numeric conversion.

Reconnection with ReplayPermalink for this section

For a brief disconnect, reconnect with the same channels and filters plus the last committed server receipt. from_seq alone requests best-effort, process-local replay:

wss://ws.sharpapi.io?api_key=YOUR_KEY&channels=ev,odds&sport=basketball&sportsbook=draftkings,fanduel&league=nba&from_seq=12900
ParameterEffect
from_seq=NAttempts replay strictly after checkpoint N

When accepted, connected includes "resumed": true; replayed frames are marked "replay": true, and the replay phase ends with snapshot:complete in "mode": "resume". Receipt of that completion frame’s last_seq is what advances the safe checkpoint. connected.global_seq on an accepted resume describes the intended replay end, not proof that all replay frames reached the client. Normal replay sends eligible frames strictly after from_seq in buffer order and catches up frames that arrived during that walk. A wide odds-only gap may instead be coalesced to current changed-row state rather than every intermediate transition. If coalescing is ineligible or exceeds its send cap, the server explicitly falls back to a full snapshot; it does not silently truncate the replay. Apply updates and removals idempotently.

When the checkpoint cannot be honored, the outcome is explicit: connected includes "resumed": false and "fallback_reason", followed by authoritative snapshots and snapshot:complete with "mode": "full_resync". Current reasons include parse_error, foreign_seq, process_restarted, seq_too_old, gap_too_large, and disabled.

If a replay starts but later completes with "gap_detected": true, reconcile from REST or obtain a fresh snapshot using one of the procedures below.

from_seq is a latency optimization over the short replay buffer, not a completeness mechanism. There is no advancing client-computable checkpoint between server receipts. Use a full snapshot or REST reconciliation whenever complete state matters.

let resumeCheckpoint; let pendingSnapshotFloor; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'connected') { // A fresh/full-resync floor becomes safe only after its snapshot finishes. pendingSnapshotFloor = msg.resumed === true ? undefined : msg.global_seq; } if (msg.type === 'snapshot:complete' && msg.mode === 'resume') { if (msg.gap_detected) { reconcileThroughRestOrRequestFreshSnapshot(); } else { // Server receipt: all replay frames before this boundary were delivered. resumeCheckpoint = msg.last_seq; } pendingSnapshotFloor = undefined; } else if ( msg.type === 'snapshot:complete' && (msg.mode === 'full_resync' || msg.mode === undefined) && pendingSnapshotFloor ) { // The authoritative fresh/full baseline is now complete. resumeCheckpoint = pendingSnapshotFloor; pendingSnapshotFloor = undefined; } if (msg.replay) { console.log('Replayed event:', msg.type); } }; // On reconnect: function reconnect() { const params = new URLSearchParams({ api_key: 'YOUR_KEY', channels: 'ev,odds' }); if (resumeCheckpoint) params.set('from_seq', resumeCheckpoint); ws = new WebSocket(`wss://ws.sharpapi.io?${params}`); }

Replay retention is nominal and best-effort, not a guaranteed duration. Process routing, deployments, time-based expiry, entry/byte eviction, and replay limits can all shorten the usable window. Durable resume_id is not currently supported as a customer checkpoint; live production frames omit it. Persist only the server-issued checkpoint receipts described above.

As verified on 2026-08-27, production runs the durable log in measurement-only shadow mode. This dated deployment setting is not a promise that durable resume is available.

Full ResynchronizationPermalink for this section

There is no client-to-server resync message. Use either supported fresh snapshot path:

  1. Reconnect without from_seq, then restore the intended channels and filters in the connection URL or subscription messages. The fresh connection sends a new authoritative snapshot.
  2. On an authenticated connection, send unsubscribe for the affected channel, optionally update filters, then subscribe to it again. The unsubscribe/resubscribe transition makes the channel new and sends a fresh snapshot.

A duplicate subscribe or filter-only update does not trigger a snapshot. The server may send resync_required after backpressure drops live deltas. Recover by reconciling through REST or by using either fresh-snapshot path above; do not echo resync_required back to the server.

Code ExamplesPermalink for this section

// 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_percentage}%`) ); 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 LimitsPermalink for this section

The cap is per API key and is shared across WebSocket and SSE. It is not per connection URL: a second socket with different channels does not get its own slot.

PlanMax concurrent streams per key
Any paid tier (streaming via the $99/mo WebSocket Add-on)1
Enterprise with a per-key maxStreams overrideCustom — still 1 until the override is granted

Opening a second connection on the same key does not reject it — it displaces the first. The new socket always connects and the older one is closed with 4001 displaced by newer session (“newer wins”). The equivalent signal on SSE is a final displaced event with reconnect: false.

A displaced client should not auto-reconnect. The slot is now held by the newer session, so reconnecting would kick that one straight back and start a reconnect loop.

The cap is coordinated across API instances — slot ownership is mirrored in shared state, not held per process — so spreading connections over several of your own hosts is not a supported way around it. To run genuinely parallel sockets, request a per-key maxStreams increase (Enterprise) or mint a separate key per process. See One Connection, Many Topics for covering many sports, leagues and books on a single socket.

429 too_many_streams is returned at the HTTP upgrade only when a key that already HAS streaming access resolves to zero slots — an explicit maxStreams: 0 override. A key without streaming access is refused earlier with 403 tier_restricted, before the upgrade, and never reaches the limiter. On a normal paid tier you get displacement instead.

Best PracticesPermalink for this section

  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 or displaced by a newer session (read the close reason to tell them apart), 4003 means entitlements changed mid-stream (downgrade, revoked key, add-on removed) — reconnect to re-authorize, after checking the plan actually still allows it
  7. Track server receipts — Commit snapshot:complete.last_seq after a successful resume, or the pending connected.global_seq floor only after its fresh/full snapshot completes; never derive from_seq from arbitrary data or heartbeat frames
  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 key allows 1 concurrent stream by default; a second connection on the same key displaces the older one (close 4001)
Last updated on