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?
WebSocket provides a persistent, full-duplex connection. Compared to SSE:
| Feature | SSE (/api/v1/stream) | WebSocket (ws.sharpapi.io) |
|---|---|---|
| Direction | Server → Client only | Bidirectional |
| Reconnection | Automatic (Last-Event-ID) | Client-managed |
| Filters | Set once via query params | Update anytime via subscribe message |
| Protocol | HTTP/1.1 streaming | WebSocket (RFC 6455) |
| Browser support | Native EventSource | Native 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_keyYou 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=nbaQuery Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
api_key | string | — | Required. Your API key |
channels | string | all | Subscribe to specific data channels, comma-separated. Valid values: ev, arbitrage, middles, low_hold, odds. Omit to receive all tier-allowed data. |
sport | string | all | Filter by sport(s), comma-separated (e.g. basketball, football, ice_hockey) |
sportsbook | string | tier-allowed | Filter by sportsbook(s), comma-separated |
league | string | all | Filter by league(s), comma-separated |
market | string | all | Filter by market type(s), comma-separated (e.g. moneyline, point_spread, total_points, player_points). Also accepted as market_type=. |
event_id | string | all | Filter by specific event ID(s), comma-separated |
min_ev | number | 2.0 | Minimum EV percentage for +EV opportunities |
min_profit | number | 0.5 | Minimum profit percentage for arbitrage and low-hold opportunities |
min_odds | number | — | Filter odds by minimum American odds value (e.g., -200) |
max_odds | number | — | Filter odds by maximum American odds value (e.g., 500) |
state | string | — | US 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_seq | string | — | Attempt 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 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
}
}| Field | Type | Description |
|---|---|---|
channels | string[] | Optional. Data channels to subscribe to: ev, arbitrage, middles, low_hold, odds. Omit to keep current channels. |
filters.sports | string[] | Optional. Filter by sport(s): basketball, football, ice_hockey, baseball, soccer, etc. |
filters.sportsbooks | string[] | Optional. Filter by sportsbook(s). |
filters.leagues | string[] | Optional. Filter by league(s). |
filters.markets | string[] | Optional. Filter by market type(s). |
filters.eventIds | string[] | Optional. Filter by specific event ID(s). |
filters.min_ev | number | Optional. Minimum EV percentage threshold (default 2.0). |
filters.min_profit | number | Optional. 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 → Client
connected
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"
}| Field | Type | Description |
|---|---|---|
seq | integer | Legacy integer representation of the process-global checkpoint. Not every control or snapshot frame includes it. |
stream_id | string | Unique connection identifier |
tier | string | Your subscription tier |
features | object | Which opportunity types your tier supports |
channels | string[] | null | Active channel subscriptions, or null if receiving all tier-allowed data |
global_seq | string | JavaScript-safe decimal string checkpoint. Treat it as opaque and store it without numeric conversion. |
resumed | boolean | On a from_seq attempt, whether replay was accepted. A decline includes fallback_reason. |
fallback_reason | string | Present when a requested resume is declined; the connection then receives a full authoritative snapshot. |
books.max | integer | Maximum sportsbooks allowed for your tier (-1 = unlimited) |
books.allowed | string[] | null | Specific allowed sportsbooks, or null for all |
subscribed
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
}
}unsubscribed
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_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",
"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).
initial
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:complete
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.
| Field | Type | Description |
|---|---|---|
books | string[] | List of sportsbooks included in the initial snapshot |
total_odds | integer | Total odds rows sent in the full snapshot |
mode | string | resume after replay, or full_resync after an explicitly declined resume. An ordinary fresh snapshot may omit it. |
fallback_reason | string | Why the requested checkpoint could not be replayed. Matches the reason in connected. |
replayed_count | integer | Normal replay: buffered frames sent. Coalesced replay: current changed rows sent. |
skipped_count | integer | Buffered frames excluded by the active subscription and filters. Coalesced replay reports 0. |
last_seq | string | Server-issued receipt for the completed replay walk. Persist it only after receiving this completion boundary. |
gap_detected | boolean | On a started replay, true means the client must reconcile state instead of treating completion as authoritative. |
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:locked
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: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_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: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"
}error
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.
| Code | Meaning |
|---|---|
invalid_message | Frame could not be parsed as JSON or did not match the expected shape |
unknown_message_type | type field is not one of auth, token_refresh, subscribe, filter, ping, unsubscribe |
missing_token | auth or token_refresh frame did not include a token field |
missing_channels | subscribe frame did not include a non-empty channels array |
not_authenticated | Sent subscribe, filter, or token_refresh before auth succeeded |
already_authenticated | Client 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 Codes
| Code | Meaning | Resolution |
|---|---|---|
1000 | Normal close | Client or server initiated clean close |
1006 | Abnormal closure (client-side) | Network drop or process kill — always reconnect |
1009 | Message 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 |
4001 | Authentication failure, or displaced by a newer session | Check your API key. If the close reason is displaced by newer session, another connection took the single per-key slot — do not auto-reconnect |
4003 | Entitlements 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 Numbers
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 Replay
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| Parameter | Effect |
|---|---|
from_seq=N | Attempts 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 Resynchronization
There is no client-to-server resync message. Use either supported fresh
snapshot path:
- 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. - On an authenticated connection, send
unsubscribefor the affected channel, optionally update filters, thensubscribeto 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 Examples
Browser
// 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 Limits
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.
| Plan | Max concurrent streams per key |
|---|---|
| Any paid tier (streaming via the $99/mo WebSocket Add-on) | 1 |
Enterprise with a per-key maxStreams override | Custom — 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 Practices
- Use channels — Subscribe only to the data you need.
channels=low_holdskips the entire odds dump and other opportunity types, reducing initial payload from megabytes to kilobytes - Send pings every 25 seconds — The server sends heartbeats every 30s, but explicit pings prevent proxy/firewall timeouts
- Use filters — Pass
sport,sportsbook,league,market, andevent_idparams to narrow data within your subscribed channels - Set thresholds — Use
min_evandmin_profitto filter out low-value opportunities at the server, reducing noise - Update via
subscribe— Change channels, filters, and thresholds without reconnecting - Handle close codes —
4001means bad key or displaced by a newer session (read the close reason to tell them apart),4003means entitlements changed mid-stream (downgrade, revoked key, add-on removed) — reconnect to re-authorize, after checking the plan actually still allows it - Track server receipts — Commit
snapshot:complete.last_seqafter a successful resume, or the pendingconnected.global_seqfloor only after its fresh/full snapshot completes; never derivefrom_seqfrom arbitrary data or heartbeat frames - Implement reconnection — Unlike SSE, WebSocket does not auto-reconnect. Use exponential backoff (1s, 2s, 4s, …) with
from_seqreplay for brief outages - Wait for
snapshot:complete— This signals all initial data has been sent. Hide loading states after receiving it - Handle
odds:removed— Remove odds from your local state when you receive this message to avoid showing stale data - Close unused connections — Each key allows 1 concurrent stream by default; a second connection on the same key displaces the older one (close
4001)
Related
- SSE Stream API Reference — Server-Sent Events alternative
- Streaming Overview — Concepts and comparison
- WebSocket Streaming Guide — Getting started guide