Player Props over WebSocket

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.

Reference Quickstart Examples Player props Edge alerts Troubleshooting

Why WebSocket for props

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:

Connect

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);
});

Market keys we push

NBA / WNBA

Scoring

  • player_points
  • player_points_alternate
  • player_first_basket
  • player_first_team_basket

Rebounds / Assists

  • player_rebounds / _alternate
  • player_assists / _alternate
  • player_points_rebounds
  • player_points_assists
  • player_points_rebounds_assists
  • player_rebounds_assists

Defense / Misc

  • player_blocks
  • player_steals
  • player_steals_blocks
  • player_turnovers
  • player_threes / _alternate

Combos / Doubles

  • player_double_double
  • player_triple_double
  • player_minutes
  • player_field_goals

MLB

Hitting

  • batter_hits / _alternate
  • batter_home_runs
  • batter_total_bases
  • batter_rbis
  • batter_runs_scored
  • batter_singles
  • batter_doubles
  • batter_triples
  • batter_walks
  • batter_strikeouts
  • batter_stolen_bases
  • batter_hits_runs_rbis

Pitching

  • pitcher_strikeouts / _alternate
  • pitcher_hits_allowed
  • pitcher_walks
  • pitcher_earned_runs
  • pitcher_outs
  • pitcher_record_a_win

NHL

Offense

  • player_points (P)
  • player_goals
  • player_assists
  • player_shots_on_goal
  • player_power_play_points
  • player_total_saves (goalies)

Physical

  • player_blocked_shots
  • player_hits
  • player_penalty_minutes

NFL

Passing

  • player_pass_yds / _alternate
  • player_pass_tds
  • player_pass_completions
  • player_pass_attempts
  • player_pass_interceptions
  • player_pass_longest_completion

Rushing / Receiving

  • player_rush_yds / _alternate
  • player_rush_attempts
  • player_rush_tds
  • player_reception_yds / _alternate
  • player_receptions / _alternate
  • player_reception_tds

Defense / Kicking

  • player_sacks
  • player_solo_tackles
  • player_tackles_assists
  • player_kicking_points
  • player_field_goals
  • player_anytime_td
  • player_1st_td / _last_td

Full 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.

Row shape — props specifically

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.

Filter to a single player

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}`);
  }
});

Filter to a single game

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.

Pinnacle / Novig anchor for +EV math

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.

How many connections do I need?

One per sport, one per stream type. For a +EV operation covering NBA + MLB + NHL + WNBA, props + game lines, the math is:

NeedConnections
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-game2
NHL pre-game + in-game2
WNBA pre-game + in-game2
Total8 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.

Volume math vs polling

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.

Common questions

Do alt-line props stream?

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.

Do props push when a player gets ruled out?

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.

Same-game-parlay / SGP props?

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.

What about PrizePicks / Underdog?

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.

Player-name normalization gotchas?

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.