TypeScript SDK
The official @sharp-api/client TypeScript SDK gives you typed access to every SharpAPI endpoint, with SSE streaming, Zod validation, and full IDE autocomplete out of the box.
Install the official SDK: npm install @sharp-api/client — GitHub
SDK Quick Start
import { SharpAPI } from '@sharp-api/client'
const api = new SharpAPI('sk_live_...')
// Get odds
const { data: odds } = await api.odds.get({ league: 'nba' })
// Get +EV opportunities (Pro+)
const { data: ev } = await api.ev.get({ min_ev: 3 })
// Get arbitrage opportunities (Hobby+)
const { data: arbs } = await api.arbitrage.get({ min_profit: 1 })
// Get middles (Pro+)
const { data: middles } = await api.middles.get({ league: 'nba' })
// SSE streaming (WebSocket add-on)
const stream = api.stream.odds({ league: 'nba' })
stream.on('update', ({ data }) => console.log(data))
stream.connect()
// WebSocket streaming (~100ms latency)
const ws = api.stream.oddsWs({ sportsbook: ['draftkings'] })
ws.on('odds:update', ({ data, source }) => console.log(source, data))
ws.connect()REST API
Use fetch to call any REST endpoint:
const API_URL = 'https://api.sharpapi.io/api/v1';
const API_KEY = 'YOUR_API_KEY';
async function sharpApi<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${API_URL}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url, { headers: { 'X-API-Key': API_KEY } });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
// Examples
const odds = await sharpApi('/odds', { sport: 'basketball', league: 'nba' });
const ev = await sharpApi('/opportunities/ev', { min_ev: '3.0' });
const arbs = await sharpApi('/opportunities/arbitrage', { min_profit: '1.0' });SSE Streaming
SSE streaming delivers real-time odds updates and opportunity alerts. This section covers how to build a correct client.
Critical: odds:update events are deltas — they only contain odds that changed. Your client must maintain local state and merge updates into it. Treating each event as a full snapshot is the #1 cause of incorrect data.
Complete TypeScript Client
const API_URL = 'https://api.sharpapi.io/api/v1';
const API_KEY = 'YOUR_API_KEY';
// ─── Types ────────────────────────────────────────────────────────────────
interface OddsLine {
id: string;
sportsbook: string;
event_id: string;
sport: string;
league: string;
home_team: string;
away_team: string;
market_type: string;
selection: string;
selection_type: string;
odds_american: number;
odds_decimal: number;
odds_probability: number;
line?: number;
event_start_time: string;
is_live: boolean;
timestamp: string;
player_name?: string; // Player prop markets only
stat_category?: string; // Player prop markets only
}
// A row in `odds:update`: `id` plus the fields that can change (odds_american,
// odds_decimal, odds_probability, line, is_live, timestamp, ...). A row for an
// id you don't hold yet is sent in full, with every OddsLine field.
type OddsDelta = Pick<OddsLine, 'id'> & Partial<OddsLine>;
interface EVOpportunity {
id: string;
ev_percentage: number;
odds_american: number;
odds_decimal: number;
selection: string;
market: string;
sportsbook: string;
game: string;
sport: string;
league: string;
is_live: boolean;
confidence_score: number;
kelly_percent: number | null;
possibly_stale: boolean;
oldest_odds_age_seconds: number | null;
warnings: string[];
detected_at: string;
}
interface ArbOpportunity {
id: string;
event_name: string;
sport: string;
market_type: string;
profit_percent: number;
possibly_stale: boolean;
oldest_odds_age_seconds: number | null;
warnings: string[];
legs: Array<{
sportsbook: string;
selection: string;
odds_american: number;
odds_decimal: number;
stake_percent: number;
}>;
detected_at: string;
}
interface LowHoldOpportunity {
id: string;
event_id: string;
event_name: string;
sport: string;
league: string;
market_type: string;
line: number | null;
hold_percentage: number;
side1: LowHoldSide;
side2: LowHoldSide;
side3: LowHoldSide | null; // 3-way markets (soccer, hockey)
is_live: boolean;
is_alternate_line: boolean;
all_books: string[];
confidence: number;
odds_age_seconds: number;
possibly_stale: boolean;
detected_at: string;
}
interface LowHoldSide {
selection: string;
books: string[];
line: number | null;
odds: {
american: number;
decimal: number;
implied_probability: number;
fair_probability: number;
};
deep_links: Record<string, string>;
}
// Payload of ev:detected / arb:detected / middles:detected / low_hold:detected.
// An item is a new opportunity OR an updated version of one already sent (same `id`).
interface DetectedEnvelope<T> {
opportunities: T[];
count: number;
type: 'ev' | 'arbitrage' | 'middles' | 'low_hold';
}
// ─── State Management ─────────────────────────────────────────────────────
// Keyed by odds line ID (e.g. "draftkings_33483153_moneyline_PHO")
const oddsMap = new Map<string, OddsLine>();
const evMap = new Map<string, EVOpportunity>();
const arbMap = new Map<string, ArbOpportunity>();
const lowHoldMap = new Map<string, LowHoldOpportunity>();
let isReady = false;
let resuming = false; // set by `connected` with `resumed: true` — see Reconnection below
// ─── Connect ──────────────────────────────────────────────────────────────
const url = new URL(`${API_URL}/stream`);
url.searchParams.set('channel', 'all');
url.searchParams.set('league', 'nba');
url.searchParams.set('api_key', API_KEY);
const eventSource = new EventSource(url.toString());
// ─── Connection lifecycle ─────────────────────────────────────────────────
function clearState() {
oddsMap.clear();
evMap.clear();
arbMap.clear();
lowHoldMap.clear();
}
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
console.log(`Connected: stream ${data.stream_id}`);
isReady = false;
// Keep local state only when the server resumed the stream (`resumed: true`):
// it replays the odds events you missed instead of sending a snapshot. Every
// other connect is followed by a full snapshot, so clear now. Only the `odds`
// channel resumes — on this `channel=all` stream every reconnect gets a snapshot.
// Don't use `reconnected` here: it is also `true` for a resume.
resuming = data.resumed === true;
if (!resuming) clearState();
});
// ─── Initial snapshot (chunked) ───────────────────────────────────────────
eventSource.addEventListener('snapshot', (e) => {
const data = JSON.parse(e.data);
// A snapshot after `resumed: true` means the server gave up on the resume:
// this snapshot replaces your state (its snapshot:complete says `full_resync`)
if (resuming) {
clearState();
resuming = false;
}
// Odds chunks carry their rows under `odds`; opportunity chunks carry
// `ev` / `arbitrage` / `middles` / `low_hold` instead
for (const odds of (data.odds ?? []) as OddsLine[]) {
oddsMap.set(odds.id, odds);
}
// Opportunity snapshots
if (data.ev) {
for (const opp of data.ev as EVOpportunity[]) {
evMap.set(opp.id, opp);
}
}
if (data.arbitrage) {
for (const arb of data.arbitrage as ArbOpportunity[]) {
arbMap.set(arb.id, arb);
}
}
if (data.low_hold) {
for (const lh of data.low_hold as LowHoldOpportunity[]) {
lowHoldMap.set(lh.id, lh);
}
}
});
eventSource.addEventListener('snapshot:complete', (e) => {
const { mode } = JSON.parse(e.data); // "resume", "full_resync", or absent on a first connect
// A full resync can arrive with zero `snapshot` chunks (nothing matches your
// filters any more), so the clear in the `snapshot` handler never ran. The
// `resuming` guard is what keeps this from wiping a snapshot already stored.
if (mode === 'full_resync' && resuming) clearState();
resuming = false;
isReady = true;
console.log(`Ready: ${oddsMap.size} odds`);
console.log(`${evMap.size} EV, ${arbMap.size} arb, ${lowHoldMap.size} low-hold opportunities`);
});
// ─── Real-time odds updates (DELTAS — merge into local state) ─────────────
// Wire fields, per the streaming event reference: `odds:update` carries
// { odds, count, book, partial }, `odds:removed` carries { ids, count, book },
// and odds rows use `odds_probability`.
// See /en/api-reference/stream/#oddsupdate for the full event reference.
eventSource.addEventListener('odds:update', (e) => {
const { odds } = JSON.parse(e.data) as { odds: OddsDelta[]; book: string; count: number; partial: boolean };
for (const delta of odds) {
const row = oddsMap.get(delta.id);
if (row) {
// Merge: a delta carries only the fields that can change, so assigning it
// over the stored row keeps sportsbook, selection, teams and the rest
Object.assign(row, delta);
} else if (delta.sportsbook !== undefined) {
// A line that opened after your snapshot arrives as a full row — store it
oddsMap.set(delta.id, delta as OddsLine);
}
// A compact delta for an id you don't hold has nothing to merge into — skip it
}
});
// ─── Odds removed (DELETE from local state) ───────────────────────────────
eventSource.addEventListener('odds:removed', (e) => {
const { ids } = JSON.parse(e.data) as { book: string; ids: string[]; count: number };
for (const id of ids) {
oddsMap.delete(id);
}
});
// ─── Opportunity events ───────────────────────────────────────────────────
eventSource.addEventListener('ev:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<EVOpportunity>;
for (const opp of opportunities) {
// Skip stale opportunities — and drop the version held under this `id`
if (opp.possibly_stale) { evMap.delete(opp.id); continue; }
const isNew = !evMap.has(opp.id);
evMap.set(opp.id, opp); // upsert — a known id is an update (new price / EV%)
if (isNew) console.log(`+EV: ${opp.selection} ${opp.ev_percentage}% on ${opp.sportsbook}`);
}
});
eventSource.addEventListener('ev:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
evMap.delete(id);
}
});
eventSource.addEventListener('arb:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<ArbOpportunity>;
for (const arb of opportunities) {
if (arb.possibly_stale) { arbMap.delete(arb.id); continue; }
const isNew = !arbMap.has(arb.id);
arbMap.set(arb.id, arb); // upsert — a known id is an update (new leg odds)
if (isNew) console.log(`Arb: ${arb.profit_percent}% — ${arb.event_name}`);
}
});
eventSource.addEventListener('arb:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
arbMap.delete(id);
}
});
eventSource.addEventListener('low_hold:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<LowHoldOpportunity>;
for (const opp of opportunities) {
if (opp.possibly_stale) { lowHoldMap.delete(opp.id); continue; }
const isNew = !lowHoldMap.has(opp.id);
lowHoldMap.set(opp.id, opp); // upsert — a known id is an update (new odds / hold%)
if (isNew) console.log(`Low Hold: ${opp.hold_percentage}% — ${opp.event_name} (${opp.market_type})`);
}
});
eventSource.addEventListener('low_hold:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
lowHoldMap.delete(id);
}
});
// ─── Health monitoring ────────────────────────────────────────────────────
let lastHeartbeat = Date.now();
eventSource.addEventListener('heartbeat', () => {
lastHeartbeat = Date.now();
});
// Check for stale connections every 60 seconds
setInterval(() => {
if (Date.now() - lastHeartbeat > 60_000) {
console.warn('No heartbeat for 60s — reconnecting');
eventSource.close();
// Re-create EventSource (browser will auto-reconnect,
// but explicit close + reconnect resets state cleanly)
}
}, 60_000);
// ─── Error handling ───────────────────────────────────────────────────────
eventSource.addEventListener('error', (e) => {
const data = JSON.parse((e as MessageEvent).data);
console.warn(`Stream error: ${data.code} — ${data.message}`);
});
eventSource.onerror = () => {
console.log('Connection lost, auto-reconnecting...');
};Node.js with eventsource Package
For server-side usage, install the eventsource package:
npm install eventsourceimport EventSource from 'eventsource';
const es = new EventSource(
'https://api.sharpapi.io/api/v1/stream?channel=all&league=nba',
{ headers: { 'X-API-Key': 'YOUR_KEY' } }
);
// Same event handlers as browser — see above
es.addEventListener('snapshot', (e) => { /* ... */ });
es.addEventListener('odds:update', (e) => { /* ... */ });
// etc.Odds Format
All odds values are returned in American format as the primary representation, with decimal and implied probability included:
// Every OddsLine includes all three formats:
{
odds_american: -110, // American odds
odds_decimal: 1.909, // Decimal odds
odds_probability: 0.524 // Implied probability (0-1)
}If you need to convert between formats yourself:
function americanToDecimal(american: number): number {
return american > 0
? american / 100 + 1
: 100 / Math.abs(american) + 1;
}
function americanToProbability(american: number): number {
return american > 0
? 100 / (american + 100)
: Math.abs(american) / (Math.abs(american) + 100);
}Staleness Metadata
EV, arbitrage, and low-hold opportunity responses include staleness information to help you filter out opportunities based on stale odds:
interface EVOpportunity {
// ... other fields ...
possibly_stale: boolean; // true if any underlying odds may be stale
oldest_odds_age_seconds: number | null; // age of the oldest odds leg
warnings: string[]; // e.g. ["SINGLE_SHARP_REF", "LIVE_STALE_ODDS"]
}
// Filter stale opportunities
eventSource.addEventListener('ev:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as { opportunities: EVOpportunity[] };
for (const opp of opportunities) {
if (opp.possibly_stale) {
console.log(`Skipping stale EV: ${opp.id}`);
continue;
}
// Process valid opportunity
}
});The EV engine emits exactly three warnings — LIVE_STALE_ODDS, SINGLE_SHARP_REF and SINGLE_SHARP_PERIOD; the arbitrage endpoint has its own taxonomy, including POTENTIALLY_STALE_ODDS (see Arbitrage Opportunities).
Reconnection
When the connection drops, EventSource reconnects on its own and sends the id of the last event it received in the Last-Event-ID header. On the odds channel the server then tries to resume: replay the odds events you missed instead of sending a new snapshot. When it can’t, it sends a full resync: a fresh snapshot that replaces your state. It tells you which one you got, in resumed on the connected event and in mode on the snapshot:complete event that ends the reconnect:
| You receive | What happened | What your client does |
|---|---|---|
connected with resumed: true, replayed odds:update / odds:removed events marked "replay": true, then snapshot:complete with mode: "resume" | Resume: the events you missed were replayed. No snapshot is sent | Keep your state and merge the replayed events like live ones |
connected with resumed: false and a fallback_reason, snapshot chunks, then snapshot:complete with mode: "full_resync" | Full resync: the server could not resume | Clear your state, then rebuild it from the snapshot |
connected with resumed: true, some replayed events, then snapshot chunks and snapshot:complete with mode: "full_resync" | The server started a resume, could not finish it, and fell back to a full resync | Clear your state when the first snapshot chunk arrives — or, if the resync carries no chunks at all, when snapshot:complete reports mode: "full_resync" — then rebuild it from the snapshot |
connected with no resumed field, then snapshot chunks | A first connect, or a reconnect that sent no Last-Event-ID | Clear anything you hold and build from the snapshot |
Resume is best-effort, not a durable log. It covers the odds channel only. A gamestate or opportunities reconnect sends no Last-Event-ID at all, so it simply re-bootstraps from a fresh snapshot and its connected event carries neither resumed nor fallback_reason. On all, odds events do set the event id, so the reconnect presents one and the server declines it with resumed: false and fallback_reason: "channel_unsupported". It also covers only a short window of recent events, so a longer outage, server maintenance, or a change of filters can end in a full resync too. The connected event’s resume descriptor states these limits on every connect, and the reliability contract lists every fallback_reason. Treat the full resync as the normal recovery path.
Don’t decide on reconnected. It is true on every reconnect that sent a Last-Event-ID, including a successful resume, so clearing on it throws away the state that the replayed events merge into.
// `resuming`, `isReady` and `clearState()` as in the client above
eventSource.addEventListener('connected', (e) => {
const { resumed, fallback_reason } = JSON.parse(e.data);
isReady = false;
resuming = resumed === true;
if (!resuming) {
// A full snapshot follows: first connect, no Last-Event-ID, or resumed: false
if (fallback_reason) console.log(`Full resync: ${fallback_reason}`);
clearState();
}
});
eventSource.addEventListener('snapshot', (e) => {
if (resuming) {
// The server gave up on the resume: this snapshot replaces your state
clearState();
resuming = false;
}
// ...store the chunk as in the client above
});
eventSource.addEventListener('snapshot:complete', (e) => {
const { mode } = JSON.parse(e.data); // "resume", "full_resync", or absent on a first connect
// A full resync can have no snapshot chunks at all, when nothing matches your filters any more
if (mode === 'full_resync' && resuming) clearState();
resuming = false;
isReady = true;
});Common Pitfalls
These are the most common mistakes when building an SSE client. Getting any of them wrong can produce phantom arbitrage or incorrect EV calculations.
1. Treating odds:update as a full snapshot
odds:update events only contain odds that changed since the last event. If you replace your entire local state with each update, you’ll only see 1-2 books at a time — making every market look like an arbitrage opportunity.
Fix: Always merge updates into your Map, never replace it.
2. Ignoring odds:removed events
When a sportsbook pulls a line (market suspended, event settled), we send odds:removed with the IDs to delete. If you don’t handle this, stale odds accumulate and create phantom arbs between removed lines and fresh ones.
Fix: Delete odds from your Map when you receive odds:removed.
3. Computing before snapshot:complete
The initial snapshot is chunked across multiple snapshot events. If you start computing arbs or EV during snapshot loading, you’ll have an incomplete picture of available markets.
Fix: Set a flag on snapshot:complete and only start calculations after that.
4. Not clearing state on reconnect
When a reconnect ends in a full snapshot and you haven’t cleared your local state, rows from the previous session stay mixed in with the new data, including lines that were removed while you were disconnected. Clearing on every reconnect is wrong too: a resume sends no snapshot, so you’d be left with only the replayed rows.
Fix: Clear all Maps when connected arrives without resumed: true, and when a snapshot chunk arrives after resumed: true. Don’t use reconnected, which is also true for a resume. See Reconnection.
5. Misinterpreting odds format
If you treat American odds (-110) as decimal odds, your calculations will produce wildly incorrect results. Our API always provides both formats — use odds_decimal for math.
6. Ignoring staleness warnings
EV and arbitrage opportunities include possibly_stale and oldest_odds_age_seconds fields. Opportunities flagged as stale may be based on odds that are several minutes old and no longer actionable.
Fix: Check possibly_stale before acting on any opportunity.