TypeScript SDK
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;
sportsbook_name: 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;
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
}
interface EVOpportunity {
id: string;
ev_percent: 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_fraction: 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>;
}
// ─── 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;
// ─── 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 ─────────────────────────────────────────────────
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
console.log(`Connected: stream ${data.stream_id}`);
// On reconnect, clear local state — server sends a fresh snapshot
if (data.reconnected) {
oddsMap.clear();
evMap.clear();
arbMap.clear();
lowHoldMap.clear();
isReady = false;
}
});
// ─── Initial snapshot (chunked) ───────────────────────────────────────────
eventSource.addEventListener('snapshot', (e) => {
const data = JSON.parse(e.data);
// Odds snapshots: keyed by sportsbook name
// e.g. { "draftkings": [OddsLine, ...], "fanduel": [OddsLine, ...] }
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value) && !['ev', 'arbitrage', 'middles', 'low_hold'].includes(key)) {
// Odds data — key is sportsbook name
for (const odds of value 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 { books, total_odds } = JSON.parse(e.data);
isReady = true;
console.log(`Ready: ${total_odds} odds from ${books.join(', ')}`);
console.log(`${evMap.size} EV, ${arbMap.size} arb, ${lowHoldMap.size} low-hold opportunities`);
});
// ─── Real-time odds updates (DELTAS — merge into local state) ─────────────
eventSource.addEventListener('odds:update', (e) => {
const data = JSON.parse(e.data);
for (const [book, odds] of Object.entries(data)) {
if (!Array.isArray(odds)) continue;
for (const line of odds as OddsLine[]) {
oddsMap.set(line.id, line); // upsert
}
}
});
// ─── Odds removed (DELETE from local state) ───────────────────────────────
eventSource.addEventListener('odds:removed', (e) => {
const { ids } = JSON.parse(e.data) as { source: string; ids: string[]; count: number };
for (const id of ids) {
oddsMap.delete(id);
}
});
// ─── Opportunity events ───────────────────────────────────────────────────
eventSource.addEventListener('ev:detected', (e) => {
const opps = JSON.parse(e.data) as EVOpportunity[];
for (const opp of opps) {
// Skip stale opportunities
if (opp.possibly_stale) continue;
evMap.set(opp.id, opp);
console.log(`+EV: ${opp.selection} ${opp.ev_percent}% 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 arbs = JSON.parse(e.data) as ArbOpportunity[];
for (const arb of arbs) {
if (arb.possibly_stale) continue;
arbMap.set(arb.id, arb);
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 opps = JSON.parse(e.data) as LowHoldOpportunity[];
for (const opp of opps) {
if (opp.possibly_stale) continue;
lowHoldMap.set(opp.id, opp);
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
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. ["POTENTIALLY_STALE_ODDS", "LIVE_STALE_ODDS"]
}
// Filter stale opportunities
eventSource.addEventListener('ev:detected', (e) => {
const opps = JSON.parse(e.data) as EVOpportunity[];
for (const opp of opps) {
if (opp.possibly_stale) {
console.log(`Skipping stale EV: ${opp.id}`);
continue;
}
// Process valid opportunity
}
});Reconnection
When the connection drops, EventSource auto-reconnects and sends the Last-Event-ID header. On reconnect, the server delivers a fresh full snapshot (not a replay of missed events). Your client should:
- Detect reconnection via
data.reconnected === truein theconnectedevent - Clear local state (
oddsMap.clear(),evMap.clear(), etc.) - Wait for the new
snapshot:completebefore acting on data
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
if (data.reconnected) {
console.log('Reconnected — clearing stale state');
oddsMap.clear();
evMap.clear();
arbMap.clear();
lowHoldMap.clear();
isReady = false;
}
});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
On reconnect, the server sends a fresh full snapshot. If you don’t clear your local state first, you’ll have duplicates and stale data mixed in.
Fix: Clear all Maps when you receive connected with reconnected: true.
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.