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:
| 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) |
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 | pa | US state code for generating sportsbook deep links in opportunity events (e.g., nj, ny, il) |
resume | boolean | false | Skip initial odds snapshot on reconnection (assumes client has previous state) |
from_seq | integer | — | Replay 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
}
}| 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). |
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"
}| Field | Type | Description |
|---|---|---|
seq | integer | Per-connection message sequence number (increments with each message) |
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 | integer | Current global event sequence number. Store this for reconnection with replay. |
books.max | integer | Maximum sportsbooks allowed for your tier (-1 = unlimited) |
books.allowed | string[] | null | Specific 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"
}| Field | Type | Description |
|---|---|---|
books | string[] | List of sportsbooks included in the initial snapshot |
resumed | boolean | true if this was a resume connection (no odds resent) |
progressive | boolean | true 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
| Code | Meaning | Resolution |
|---|---|---|
1000 | Normal close | Client or server initiated clean close |
4001 | Authentication failure | Check your API key |
4003 | No streaming access | Add WebSocket add-on ($99/mo) or upgrade to Enterprise |
4029 | Stream limit exceeded | Close 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
seqis monotonically increasing - Gap detection — A gap in
seqmeans a message was lost (e.g. due to backpressure) - Reconnection replay — Pass
global_seqasfrom_seqon 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| Parameter | Effect |
|---|---|
resume=true | Skips the full odds snapshot (assumes client has previous state) |
from_seq=N | Replays 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
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_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.
| Plan | Max Concurrent Streams |
|---|---|
| WebSocket Add-on ($99/mo) | 10 |
| Enterprise | Custom |
Exceeding your stream limit closes the connection with code 4029. Close unused connections before opening new ones.
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,4003means no streaming access,4029means too many connections - Track sequence numbers — Store
global_seqfor replay on reconnection. Useresume=true&from_seq=Nfor gap-free recovery - 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 connection counts against your 10-stream limit
Related
- SSE Stream API Reference — Server-Sent Events alternative
- Streaming Overview — Concepts and comparison
- WebSocket Streaming Guide — Getting started guide