Live player-prop streaming for NBA, MLB, NHL, WNBA, and NFL. Same socket as game lines, same protocol, just filter for the prop markets you care about. Built for +EV volume bettors and prop-arb operators who can't wait on REST polling.
Prop lines move faster than game lines, especially after lineup news and in the 30 minutes before tip / first pitch / puck drop. Polling REST at 1 Hz means you miss 1 second of edge per cycle — and you waste 80–90% of your credits on no-change cycles when nothing's moving. Over WebSocket:
Player props are pushed on the same stream as game lines. No separate endpoint, no separate parameter.
wss://parlay-api.com/v1/ws/odds/basketball_nba?apiKey=YOUR_KEY
If you only want props (and not game lines), filter client-side on market_key prefix player_:
// Node — drop game-line rows, keep props only
ws.on("message", (buf) => {
const f = JSON.parse(buf.toString());
if (f.type !== "odds_update") return;
const props = f.data.filter(r => r.market_key.startsWith("player_"));
if (props.length) handleProps(props);
});
player_pointsplayer_points_alternateplayer_first_basketplayer_first_team_basketplayer_rebounds / _alternateplayer_assists / _alternateplayer_points_reboundsplayer_points_assistsplayer_points_rebounds_assistsplayer_rebounds_assistsplayer_blocksplayer_stealsplayer_steals_blocksplayer_turnoversplayer_threes / _alternateplayer_double_doubleplayer_triple_doubleplayer_minutesplayer_field_goalsbatter_hits / _alternatebatter_home_runsbatter_total_basesbatter_rbisbatter_runs_scoredbatter_singlesbatter_doublesbatter_triplesbatter_walksbatter_strikeoutsbatter_stolen_basesbatter_hits_runs_rbispitcher_strikeouts / _alternatepitcher_hits_allowedpitcher_walkspitcher_earned_runspitcher_outspitcher_record_a_winplayer_points (P)player_goalsplayer_assistsplayer_shots_on_goalplayer_power_play_pointsplayer_total_saves (goalies)player_blocked_shotsplayer_hitsplayer_penalty_minutesplayer_pass_yds / _alternateplayer_pass_tdsplayer_pass_completionsplayer_pass_attemptsplayer_pass_interceptionsplayer_pass_longest_completionplayer_rush_yds / _alternateplayer_rush_attemptsplayer_rush_tdsplayer_reception_yds / _alternateplayer_receptions / _alternateplayer_reception_tdsplayer_sacksplayer_solo_tacklesplayer_tackles_assistsplayer_kicking_pointsplayer_field_goalsplayer_anytime_tdplayer_1st_td / _last_tdFull per-sport market list lives at GET /v1/sports/{sport_key}/markets. New markets get added over the season — the endpoint always reflects what's live right now.
Player-prop rows carry two fields game-line rows don't:
{
"event_id": "2026-05-13_Boston_Celtics_New_York_Knicks",
"bookmaker": "draftkings",
"market_key": "player_points",
"outcome": "Jayson Tatum Over", // "<Player Name> Over" | "<Player Name> Under"
"player": "Jayson Tatum", // ← player canonical name
"line": 28.5, // ← the point line (null for game lines)
"price_american": -118,
"price_decimal": 1.847,
"last_update": "2026-05-13T23:24:18Z"
}
The player field is the canonical name (we normalize across books — DraftKings' "Jayson Tatum" and FanDuel's "J. Tatum" both come through as Jayson Tatum). Use it to filter for a specific player without having to fuzzy-match book-by-book.
Client-side filter, no server config needed:
const TRACKED_PLAYERS = new Set([
"Jayson Tatum",
"Luka Doncic",
"Shai Gilgeous-Alexander",
]);
ws.on("message", (buf) => {
const f = JSON.parse(buf.toString());
if (f.type !== "odds_update") return;
const watch = f.data.filter(r =>
r.market_key.startsWith("player_") &&
TRACKED_PLAYERS.has(r.player));
for (const row of watch) {
console.log(`${row.player.padEnd(28)} ${row.market_key.padEnd(20)} `
+ `${row.outcome.endsWith("Over") ? "O" : "U"} ${row.line} `
+ `${row.bookmaker.padEnd(12)} ${row.price_american > 0 ? "+" : ""}${row.price_american}`);
}
});
Send a subscribe frame with the event_id after connect:
ws.on("open", () => {
setTimeout(() => {
ws.send(JSON.stringify({
type: "subscribe",
event_id: "2026-05-13_Boston_Celtics_New_York_Knicks"
}));
}, 100);
});
From that point on, only rows for that game come through. Combine with client-side market_key.startsWith("player_") filter to get props for one game only.
Most +EV operators anchor on Pinnacle or Novig as the "sharp" market and look for offshore / US-book softlines that deviate from it. Both books push on the same socket — filter by bookmaker:
const SHARP_BOOKS = new Set(["pinnacle", "novig"]);
const SOFT_BOOKS = new Set(["draftkings", "fanduel", "betmgm", "caesars", "fanatics"]);
const sharpCache = new Map(); // (event|player|market|line|side) → sharp_decimal
const softCache = new Map(); // same key → { book → decimal }
function key(r) {
const side = r.outcome.endsWith("Over") ? "O" : "U";
return `${r.event_id}|${r.player}|${r.market_key}|${r.line}|${side}`;
}
ws.on("message", (buf) => {
const f = JSON.parse(buf.toString());
if (f.type !== "odds_update") return;
for (const row of f.data) {
if (!row.market_key.startsWith("player_")) continue;
const k = key(row);
if (SHARP_BOOKS.has(row.bookmaker)) {
sharpCache.set(k, row.price_decimal);
checkEdge(k);
} else if (SOFT_BOOKS.has(row.bookmaker)) {
const bucket = softCache.get(k) || {};
bucket[row.bookmaker] = row.price_decimal;
softCache.set(k, bucket);
checkEdge(k);
}
}
});
function checkEdge(k) {
const sharp = sharpCache.get(k);
const softs = softCache.get(k);
if (!sharp || !softs) return;
// No-vig the sharp: pair this side with the opposite side first.
// (full pattern in /docs/websocket/edge-alerts)
const sharpImpliedFair = 1 / sharp; // simplified — needs vig removal in production
for (const [book, dec] of Object.entries(softs)) {
const softImplied = 1 / dec;
const edge = sharpImpliedFair - softImplied;
if (edge > 0.025) { // +2.5% EV
console.log(`+EV ${(edge*100).toFixed(1)}% ${k} @ ${book} ${dec}`);
}
}
}
For the proper no-vig pair math + Pinnacle fallback chain, see the edge-alerts page or the /v1/clv endpoint which does the math server-side and returns CLV per bet.
One per sport, one per stream type. For a +EV operation covering NBA + MLB + NHL + WNBA, props + game lines, the math is:
| Need | Connections |
|---|---|
| NBA all markets (pre-game) | 1 → /v1/ws/odds/basketball_nba |
| NBA all markets (in-game) | 1 → /v1/ws/live/basketball_nba |
| MLB pre-game + in-game | 2 |
| NHL pre-game + in-game | 2 |
| WNBA pre-game + in-game | 2 |
| Total | 8 concurrent |
8 concurrent is well under any paying tier cap (Business=100, Enterprise/Scale=1000). If you spin up an arbitrage bot per sport, each bot needs its own connection pair — count one per worker, not one per box.
A typical +EV operator polling REST at 2 Hz across NBA + MLB pre-game + in-game burns ~700k credits/day on no-change cycles. The same coverage over WebSocket is ~5k credits/day for the persistent connections plus actual change events (typically <30k/day total). That's a 20–30× reduction in credit burn for the same data.
Yes. _alternate markets come through the same socket. The row will have market_key ending in _alternate (e.g. player_points_alternate) and the line field set to the alternate value.
When a book removes a market, you see a price change to a "void" / null price, depending on the book's convention. We don't fabricate a "removed" event — the absence of new updates is the signal that the market was pulled. If a row stops appearing for > 5 minutes and the game's tipping soon, treat the line as stale.
The stream pushes individual prop prices. To price an SGP, take the per-leg prices and call POST /v1/parlay/price with your legs — that endpoint does the multi-leg multiplication, dedupes identical legs, and rejects mutually-exclusive ones.
We carry PrizePicks and Underdog projections in /v1/sports/{sport_key}/props (REST). They don't currently push on WebSocket — both are pick-em / fantasy products and don't move continuously the way book lines do. If you need real-time PrizePicks / Underdog deltas, poll their REST endpoints every 60–90 s.
We normalize 99% of names. The remaining 1% — usually rookies, mid-season call-ups, or international hockey players with non-ASCII characters — may take a few minutes after first appearance to land in the normalization map. If you see a name that doesn't match across books, please file a ticket and we'll add it within an hour.