npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

propline

v0.52.0

Published

Node.js / TypeScript SDK for the PropLine player props betting odds API

Downloads

5,479

Readme

PropLine Node.js / TypeScript SDK

Official Node and TypeScript client for the PropLine player props API — real-time betting odds from Bovada, DraftKings, FanDuel, Pinnacle, Unibet, and PrizePicks across MLB, NBA, NHL, soccer, UFC, and more.

Zero runtime dependencies — uses the built-in fetch. Requires Node 18+.

Installation

npm install propline
# or
pnpm add propline
# or
yarn add propline

Quick start

import { PropLine } from "propline";

const client = new PropLine("your_api_key");

// List available sports
const sports = await client.getSports();
// [{ key: "baseball_mlb", title: "MLB", active: true }, ...]

// Get today's NBA games
const events = await client.getEvents("basketball_nba");
for (const event of events) {
  console.log(`${event.away_team} @ ${event.home_team}`);
}

// Get player props for a game
const odds = await client.getOdds("basketball_nba", {
  eventId: events[0].id,
  markets: ["player_points", "player_rebounds", "player_assists"],
});

for (const bookmaker of odds.bookmakers) {
  for (const market of bookmaker.markets) {
    for (const outcome of market.outcomes) {
      console.log(
        `${outcome.description} ${outcome.name} ${outcome.point} @ ${outcome.price}`
      );
    }
  }
}

CommonJS works too:

const { PropLine } = require("propline");

Get your API key

  1. Go to prop-line.com

  2. Enter your email

  3. Get your API key instantly — 1,000 requests/day, no credit card required

    Paid plans: Hobby $9/mo (5,000 req/day, full analytics), Pro $19/mo (25,000/day + CSV exports), Streaming Lite $39/mo (250,000/day + webhooks/websocket), Streaming $79/mo (1,000,000/day), Enterprise (unlimited).

Available sports

| Key | Sport | |-----|-------| | baseball_mlb | MLB | | basketball_nba | NBA | | basketball_ncaab | College Basketball | | football_ncaaf | College Football | | golf | Golf | | tennis | Tennis | | hockey_nhl | NHL | | football_nfl | NFL | | soccer_epl | EPL | | soccer_la_liga | La Liga | | soccer_serie_a | Serie A | | soccer_bundesliga | Bundesliga | | soccer_ligue_1 | Ligue 1 | | soccer_mls | MLS | | mma_ufc | UFC | | boxing | Boxing |

Migrating from the-odds-api? Their sport key names work as aliases (americanfootball_nfl, icehockey_nhl, soccer_spain_la_liga, mma_mixed_martial_arts, ...) so only the base URL changes. Aliases exist only where the competition is identical; anything else returns a structured 404 with did_you_mean rather than a silently-different feed.

Bookmakers

Every odds response returns a bookmakers array so you can compare lines across books in a single request — iterate the array to line-shop.

| Key | Book | Coverage | |-----|------|----------| | bovada | Bovada | All 19 sports — game lines + full player props | | draftkings | DraftKings | MLB, NBA, NHL, 6 soccer leagues — game lines + player props | | fanduel | FanDuel | MLB, NBA, NHL, 6 soccer leagues — game lines + player props | | pinnacle | Pinnacle | MLB (game lines + props), NBA/NHL/soccer (game lines, goalie saves) | | unibet | Unibet | MLB/NBA/NHL + 6 soccer leagues — game lines; player props on NBA, NHL, soccer | | prizepicks | PrizePicks (DFS) | MLB, NBA, WNBA, NHL, tennis, UFC, soccer — player props only; synthetic +100/+100 even-money pricing since DFS payouts scale with parlay correct-count, not per-pick odds. Each outcome carries dfs_odds_type (standard = the market line, goblin = easier/lower-payout, demon = harder/higher-payout). Filter to standard for the market line; goblin/demon arrive as their own per-line markets (e.g. Points (demon 27.5)). Each goblin/demon outcome also carries line_gap — the signed delta from that player+stat's standard line (+demon harder / -goblin easier; null when no standard counterpart) | | underdog | Underdog Fantasy (DFS) | MLB, NBA, NHL, tennis, UFC, 9 soccer leagues — player props with real two-way American prices and a payout_multiplier on every outcome (1.0 = standard pick; e.g. 1.5 boost / 0.75 discount; null only means the book is not Underdog). Keep only payout_multiplier === 1.0 when comparing DFS lines to sportsbook consensus — filtering on non-null would drop every Underdog line |

import { PropLine, Bookmakers } from "propline";

const client = new PropLine("your_api_key");

const odds = await client.getOdds("baseball_mlb", {
  eventId: 51,
  markets: ["pitcher_strikeouts"],
});

// Filter to a specific book
for (const bk of odds.bookmakers) {
  if (bk.key === Bookmakers.DRAFTKINGS) {
    // ...
  }
}

// Or iterate all books
for (const bk of odds.bookmakers) {
  console.log(`\n${bk.title}`);
  for (const market of bk.markets) {
    for (const o of market.outcomes) {
      console.log(`  ${o.description} ${o.name} ${o.point}: ${o.price}`);
    }
  }
}
// Bovada
//   Zack Wheeler Over 6.5: -130
// DraftKings
//   Zack Wheeler Over 6.5: -125
// FanDuel
//   Zack Wheeler Over 6.5: -135

Available markets

MLB

pitcher_strikeouts, pitcher_outs, pitcher_earned_runs, pitcher_hits_allowed, batter_hits, batter_home_runs, batter_rbis, batter_total_bases, batter_stolen_bases, batter_walks, batter_singles, batter_doubles, batter_runs, batter_2plus_hits, batter_2plus_home_runs, batter_2plus_rbis, batter_3plus_rbis

NBA

player_points, player_rebounds, player_assists, player_threes, player_steals, player_blocks, player_turnovers, player_points_rebounds, player_points_assists, player_rebounds_assists, player_points_rebounds_assists, player_double_double, player_triple_double

NHL

player_goals, player_first_goal, player_goals_2plus, player_goals_3plus, player_shots_on_goal, player_points_1plus, player_points_2plus, player_points_3plus, goalie_saves, player_blocked_shots

Soccer (EPL, La Liga, Serie A, Bundesliga, Ligue 1, MLS)

anytime_goal_scorer, first_goal_scorer, 2plus_goals, goal_or_assist, player_assists, player_2plus_assists, player_cards, both_teams_to_score, double_chance, draw_no_bet, correct_score, total_corners, team_corners, corners_spread, total_cards

UFC / Boxing

h2h, total_rounds, fight_distance, round_betting

Football (NFL, NCAAF)

player_pass_yds, player_pass_tds, player_rush_yds, player_reception_yds, player_receptions, player_anytime_td, player_1st_td, player_2plus_td, winning_margin, half_time_full_time, overtime

Golf

tournament_winner, player_make_cut, player_top_5, player_top_10, player_top_20

Tennis

h2h, spreads (games), totals (games), total_sets, player_aces, player_games_won

Game lines (all sports)

h2h, spreads, totals (alt lines and team totals included automatically)

Kalshi (bookmakers=kalshi) quotes many of these beyond game lines — NFL team totals (full game and 1st half), winning margin, half-time/full-time, La Liga + EPL total_corners / team_corners, La Liga goalscorers, WTA 125 / ATP Challenger match winners, fight_distance, and golf winner / make-cut / top 5, 10, 20 on the PGA and DP World Tours.

A team total rides the same totals key as the game total, so one book can return several totals markets on one event. Read the market's team field to tell them apart — it carries the canonical event team name (matching home_team / away_team exactly) on a team total and is null on the game total:

const gameTotals = market.team === null || market.team === undefined;
const arsenalTotal = market.team === event.home_team;

team is always null outside totals, and is present on odds, odds history, closing lines and movement. The book's own description is still there as the human-readable label, but every book words it differently (Bovada suffixes " - {team}", BetUS prefixes "Team Total - ", Smarkets and TAB say nothing), so prefer team over parsing that string.

Examples

Get MLB pitcher strikeout props

import { PropLine } from "propline";

const client = new PropLine("your_api_key");

const events = await client.getEvents("baseball_mlb");
for (const event of events) {
  const odds = await client.getOdds("baseball_mlb", {
    eventId: event.id,
    markets: ["pitcher_strikeouts"],
  });
  console.log(`\n${event.away_team} @ ${event.home_team}`);
  for (const bk of odds.bookmakers) {
    for (const mkt of bk.markets) {
      for (const o of mkt.outcomes) {
        if (o.point != null) {
          console.log(`  ${o.description} ${o.name} ${o.point}: ${o.price}`);
        }
      }
    }
  }
}

Filter to specific bookmakers

Every odds endpoint (getOdds, getOddsHistory, getOddsClosing, getMovement) accepts a bookmakers option — a bookmaker key or array of keys, same parameter name as the-odds-api — to restrict the response to specific books:

const odds = await client.getOdds("baseball_mlb", {
  eventId: 12345,
  markets: ["pitcher_strikeouts"],
  bookmakers: ["draftkings", "fanduel"], // omit for all books
});

Event-page links (click out to the book)

getOdds and getEventBestLine accept includeLinks: true — each bookmaker block (odds) or price row (best-line) then carries a link: that book's public event-page URL, so your UI can click out from a line straight to the book. Plain navigation, no affiliate tagging. Links ship for Bovada, DraftKings, FanDuel, BetMGM, Kalshi, Polymarket and Smarkets; other books return null. The same flag also adds app_link — a mobile app-open deep link that opens the book's native app on the fixture (app-store fallback otherwise), vs link = the desktop web page. ProphetX only today; null elsewhere.

const bl = await client.getEventBestLine("baseball_mlb", 12345, {
  includeLinks: true,
});
for (const line of bl.lines) {
  for (const [side, info] of Object.entries(line.sides)) {
    console.log(`${side}: ${info.best.price} @ ${info.best.book_title} -> ${info.best.link}`);
  }
}

Native book ids (join onto a book's own data)

getOdds accepts includeBookIds: true — each bookmaker block then carries a book_event_id and each outcome a book_outcome_id: that book's OWN identifiers for the event and the priced selection. Use them to join PropLine rows onto a book's native feed by id, instead of fuzzy-matching team names, player names and lines.

Kalshi ships both — the event ticker and the per-contract market ticker — which makes this the leg-level join key if you already pull Kalshi's own API. Most other books ship an event id; books without a stable public id return null.

const event = await client.getOdds("baseball_mlb", {
  eventId: 12345,
  markets: ["h2h"],
  includeBookIds: true,
});
for (const book of event.bookmakers) {
  console.log(book.key, book.book_event_id);
  for (const m of book.markets) {
    for (const o of m.outcomes) {
      console.log("   ", o.name, o.book_outcome_id);
    }
  }
}

Note a two-sided market can share one book_outcome_id across both legs: a Kalshi contract is binary, so Over and Under are its YES and NO sides. The id identifies the contract; the outcome's name tells you which side.

Exchange liquidity (is the price actually bettable?)

ProphetX is a peer-to-peer exchange, so its best price is often a thin dangling offer with only a few dollars behind it. Every ProphetX outcome carries liquidity — the dollars you can actually stake at the quoted price — so you can filter or flag quotes that are only good for a buck. null for books without a resting-size signal. The same field rides every price row on getBestLine, where a thin exchange quote often wins the best slot on price alone. Pinnacle carries it too (since 2026-09-10): there it is the book's posted max risk stake on the market, and a Pinnacle limit change with no price move is its own row in getOddsHistory (liquidity on every snapshot) — getOddsClosing carries opening_liquidity beside liquidity, so you can see whether the limit went up as the line moved.

const event = await client.getOdds("baseball_mlb", { eventId: 12345 });
for (const book of event.bookmakers) {
  if (book.key !== "prophetx") continue;
  for (const m of book.markets) {
    for (const o of m.outcomes) {
      if (o.liquidity != null && o.liquidity < 25) {
        console.log(`thin: ${m.key} ${o.name} ${o.price} ($${o.liquidity})`);
      }
    }
  }
}

Join the same player across books (player_id)

book_outcome_id joins a row onto one book's own feed. player_id does the complement: it joins the same player across books, without name matching. Every player-prop outcome carries it, unconditionally — no option to pass — on getOdds and getEventResults.

It is the league's own permanent id, namespaced: mlb:592450 (MLBAM), nba:/wnba: (CDN personId), nhl: (playerId), espn:8439 (ESPN athlete id — soccer/NFL/NCAAF). A real league id rather than a name-hash, so it distinguishes two players with the same name, is stable across seasons, and cross-references to the league's own API.

It is null whenever we lack a confirmed, unambiguous id — and never guessed, because a wrong join is worse than a missed one: a sport with no stable-id stats feed (tennis/golf/UFC/… — null forever), a player who has never graded, a book spelling that diverges from the league's ("Elmer Rodríguez" gets the id, "Elmer Rodriguez Cruz" stays null), or a name two players share. Coverage warms as games grade after launch.

// One player's line across every book, joined by id not name.
const event = await client.getOdds("baseball_mlb", { eventId: 12345 });
const byId: Record<string, { book: string; price: number }[]> = {};
for (const book of event.bookmakers)
  for (const m of book.markets)
    for (const o of m.outcomes)
      if (o.player_id)
        (byId[o.player_id] ??= []).push({ book: book.key, price: o.price });

Filter to game-period markets

Every odds endpoint accepts a period option to scope results to first-quarter / first-half / first-period / first-N-innings markets. Omit it for full-game markets — the default behavior is unchanged.

// First-quarter NBA totals
const q1 = await client.getOdds("basketball_nba", {
  eventId: 12345,
  markets: ["totals"],
  period: "q1",          // q1|q2|q3|q4 | h1|h2 | p1|p2|p3 | i1..i9 | f3|f5|f7
});

// Multiple periods in one call — array or comma-separated string
const both = await client.getOdds("basketball_nba", {
  eventId: 12345,
  markets: ["totals"],
  period: ["q1", "q2"],
});

// Pass period: "all" to include every period alongside the full-game row.

Every response row carries a period field so you can bucket client-side. Coverage today: Bovada / DraftKings / FanDuel / Pinnacle on NBA / NHL / MLB / soccer. Football period markets land at NFL preseason (August 2026). The same period option works on getOddsHistory() and getOddsClosing().

Get game scores

const scores = await client.getScores("baseball_mlb");
for (const game of scores) {
  if (game.status === "final") {
    console.log(
      `${game.away_team} ${game.away_score}, ${game.home_team} ${game.home_score}`
    );
  }
}

Get game context — pitchers, umpire, weather (free)

const ctx = await client.getContext("baseball_mlb", 37464);
console.log(`${ctx.away_probable_pitcher} (${ctx.away_probable_pitcher_hand}) @ ` +
            `${ctx.home_probable_pitcher} (${ctx.home_probable_pitcher_hand})`);
console.log(`Umpire: ${ctx.home_plate_umpire}  Lineup set: ${ctx.lineup_confirmed}`);
if (ctx.weather) {
  const w = ctx.weather;
  console.log(`${w.temperature_f}F, wind ${w.wind_speed_mph}mph ${w.wind_direction}, ${w.conditions}`);
}

The conditions a prop settles under. For MLB: probable starting pitchers and their throwing hand (home_probable_pitcher_hand / away_probable_pitcher_hand, "L"/"R"/"S" — platoon-split context for every batter prop), a confirmed-lineup flag, the home-plate umpire, and first-pitch weather at outdoor / open-roof venues. For NFL & NCAAF: the venue and kickoff weather (pitcher/umpire/lineup fields are null for football). The same block is embedded in getResults(), so every graded prop carries its conditions — unique to PropLine. Free tier. Rejects with a 404 when no context is on file for the event yet.

Get line movement & steam (Hobby+)

const mv = await client.getMovement("baseball_mlb", 37464);
for (const s of mv.steam) {
  console.log(`${s.name} ${s.consensus_direction} (${s.books_moved}/${s.books_quoting} books, score ${s.steam_score})`);
}

Line movement derived from our snapshot tick history. Per (book, market, outcome): opening line, latest line, implied-probability + point shift, direction. The steam array flags outcomes multiple books moved the same direction — the sharp-money signal across every book we poll. Unique to PropLine. Hobby+ full; free tier redacted.

Get resolution coverage summary (free)

const s = await client.getResolutionSummary(30);
console.log(
  `${s.total_graded.toLocaleString()} props graded across ` +
    `${s.sports_covered} sports in ${s.days}d`
);
for (const row of s.by_sport.slice(0, 5)) {
  console.log(`  ${row.title}: ${row.graded.toLocaleString()} (${row.events} games)`);
}

Get resolved prop outcomes (Pro only)

const results = await client.getResults("baseball_mlb", 16, {
  markets: ["pitcher_strikeouts", "batter_hits"],
});

console.log(
  `${results.away_team} ${results.away_score}, ${results.home_team} ${results.home_score}`
);

for (const market of results.markets) {
  for (const outcome of market.outcomes) {
    console.log(
      `${outcome.description} ${outcome.name} ${outcome.point}: ${outcome.resolution} (actual: ${outcome.actual_value})`
    );
  }
}
// Output: "Tarik Skubal (DET) Over 6.5: won (actual: 7.0)"

Get historical line movement (Hobby+)

const history = await client.getOddsHistory("baseball_mlb", 16, {
  markets: ["pitcher_strikeouts"],
});

for (const book of history.bookmakers) {
  for (const market of book.markets) {
    for (const outcome of market.outcomes) {
      console.log(`\n[${book.key}] ${outcome.description}:`);
      for (const snap of outcome.snapshots) {
        console.log(
          `  ${snap.recorded_at}: ${snap.price} @ ${snap.point}` +
            ` (book reported: ${snap.book_updated_at ?? "n/a"})`,
        );
      }
    }
  }
}

Each snapshot carries up to three change-detection signals: recorded_at (when our scraper saw the odds), book_updated_at (when the book itself reports the price was last set — Bovada today), and book_version (per-market monotonic counter — Pinnacle today). The gap between recorded_at and book_updated_at is per-book scraper latency; deltas in book_version between two snapshots tell you how many distinct market updates the book recorded between them, even when the visible price didn't change. See https://prop-line.com/docs#timestamps for the full semantic.

Period-historical filters

Combine any of these to scope, downsample, and de-noise:

// Last 30 minutes of moves before tip, only when the line actually changed.
const moves = await client.getOddsHistory("baseball_mlb", 16, {
  markets: ["pitcher_strikeouts"],
  relativeFrom: "-30m",
  relativeTo: "0",
  changesOnly: true,
});

// One snapshot per minute for the 3 hours before commence.
const ts = await client.getOddsHistory("baseball_mlb", 16, {
  markets: ["pitcher_strikeouts"],
  relativeFrom: "-3h",
  relativeTo: "0",
  interval: "1m", // "30s" | "1m" | "5m" | "15m" | "30m" | "1h"
});
  • from / to: absolute ISO timestamps.
  • relativeFrom / relativeTo: offsets relative to commence_time (-3h, -30m, -90s, 0). Mutually exclusive with the absolute counterpart.
  • interval: downsample to one snapshot per bucket; latest snapshot in each bucket wins.
  • changesOnly: drop adjacent snapshots whose (price, point) match. Opening line is always kept.

Get opening & closing lines / CLV (Hobby+)

One call returns both ends of the move per (book, market, outcome): the last snapshot at or before commence_time (price / point / closing_at) and the first snapshot in the same 14-day pre-kickoff window (opening_price / opening_point / opening_at).

const closing = await client.getOddsClosing("baseball_mlb", 5885, {
  markets: ["pitcher_strikeouts"],
});

for (const book of closing.bookmakers) {
  for (const m of book.markets) {
    for (const o of m.outcomes) {
      if (o.description !== "Bryan Woo" || o.name !== "Over") continue;
      console.log(
        `${book.key}: opened ${o.opening_price} @ ${o.opening_point} ` +
        `-> closed ${o.price} @ ${o.point} (${o.closing_at})`
      );
    }
  }
}

Compare the points, not just the prices. On spreads and totals the number moves as much as the price (6.5 → 7.0), so a price-only comparison silently mis-measures those markets.

opening_age_seconds is how long before kickoff the opener was recorded. The archive starts April 2026, so for a book/sport PropLine began polling after a line was posted, opening_* means first observed by us rather than the book's true open — a value in minutes rather than hours is the tell.

Grade your bets against the close (Hobby+)

getOddsClosing gives you the closing line; gradeClv does the whole job — send the bets you actually placed and get CLV, the de-vigged closing fair, and the graded result back per bet. Stateless: nothing is stored.

const res = await client.gradeClv([
  {
    ref: "b1",
    sport_key: "baseball_mlb",
    event_id: 150791,
    market: "batter_hits_runs_rbis",
    bookmaker: "lowvig",
    selection: "Drake Baldwin",
    side: "Under",
    point: 0.5,
    price: 145,
    stake: 1,
  },
]);

const s = res.summary;
console.log(
  `${s.matched}/${s.bets} matched · beat the close ${s.beat_close_pct}% · ` +
  `${s.profit_units}u`
);

for (const b of res.bets) {
  if (!b.matched) {
    console.log(`${b.ref}: unmatched (${b.unmatched_reason})`);
    continue;
  }
  console.log(
    `${b.ref}: took ${b.price} vs close ${b.closing_price} -> ` +
    `CLV ${b.clv_pct}% · vs de-vigged close ${b.ev_vs_close_pct}% ` +
    `(${b.fair_source}) -> ${b.resolution}`
  );
}

Two CLV numbers, and they disagree on purpose. clv_pct is price-vs-price: familiar and quotable, but vig-blind, so it flatters a bet taken on the juicy side of a wide market. ev_vs_close_pct scores your price against the de-vigged close and is the honest one — a -110 taken into a -105/-115 close beat the price but not the fair line. On a real bet the two came out +6.52% and +0.08%.

The de-vig anchors to the sharpest book quoting that line at close (fair_source), not the book you bet at — de-vigging your own book always returns a negative number, because you paid its hold.

Bets whose event hasn't started carry closing_is_final: false, land in summary.pending, and are excluded from the averages: before kickoff the "closing" price is just the latest price, so CLV is ~0 by construction.

Matching is fail-closed. A bet that can't be pinned to exactly one stored outcome comes back matched: false with an unmatched_reason (event_not_found, no_market_for_key, no_outcome_for_selection, ambiguous_selection, no_closing_snapshot) rather than a confident wrong match. Lines match by equality, never nearest-value — 0.5 and 1.5 are different bets. Max 500 bets per request.

Price a same-game parlay at the book's own odds (Hobby+)

priceSgp returns the book's own correlated price for a slip — what a FanDuel customer would be offered for it right now — beside the independent product of the single-leg prices and their ratio.

const q = await client.priceSgp("baseball_mlb", 150791, [
  { market: "h2h", name: "St. Louis Cardinals" },
  { market: "batter_1plus_hits", name: "Freddie Freeman", description: "Freddie Freeman" },
]);
console.log(q.quoted, q.sgp_price, q.independent_price, q.correlation_factor);
// true 592 322 1.6387

Legs are named exactly as /odds names an outcome (or by book_outcome_id from includeBookIds; on betonlineag / lowvig that is Sportcast's settlement id, e.g. MatchWinner_Home). For a team total set team to the team as /odds serves it in the market's team field; a totals leg with no team matches the game total only. Matching is fail-closed: a leg that does not pin to exactly one stored outcome is a 422 naming the leg. quoted: false means the book will not offer that combination as a same-game parlay; refused legs carry the book's own failure_code. Books: fanduel, betonlineag, lowvig.

Get player prop history (Pro full, Free redacted)

// "Did Bryan Woo go over/under his last 10 strikeout props?"
const hist = await client.getPlayerHistory("baseball_mlb", "Bryan Woo", {
  market: "pitcher_strikeouts",
  limit: 10,
});

for (const e of hist.entries) {
  console.log(
    `${e.commence_time.slice(0, 10)} ${e.bookmaker_title}: ` +
      `line ${e.line}, actual ${e.actual_value} ` +
      `-> Over ${e.over_result}, Under ${e.under_result}`
  );
}
// Output: "2026-04-19 DraftKings: line 6.5, actual 6.0 -> Over lost, Under won"

Player game log / head-to-head (free)

// Every raw box-score stat, per game, in one call.
const log = await client.getPlayerGames("baseball_mlb", "Aaron Judge", { limit: 10 });
for (const g of log.games) {
  const where = g.is_home ? "vs" : "@";
  console.log(`${g.commence_time.slice(0, 10)} ${where} ${g.opponent}: ${g.stats.hits ?? 0} H`);
}

// Head-to-head. Accepts a name, nickname or abbreviation, and the limit
// applies AFTER the filter — this is the last 5 MEETINGS with Boston.
const h2h = await client.getPlayerGames("baseball_mlb", "Aaron Judge", {
  limit: 5,
  opponent: "BOS",
});

Reads the raw-stats archive rather than graded-prop history, so it covers every game with a box score on file — including games no sportsbook priced. A "last 10 games" window here really is the last 10 games. Carries no line, price or grade; use getPlayerTrends for hit rates against a posted line.

Get player trends (Pro full, Free redacted)

// Rolling over/under hit-rates per market: last 5/10/20/50 graded games,
// current streak, and the most recent game. Omit `market` for all markets.
// Pass `dfsOddsType: "standard" | "goblin" | "demon"` to compute the trend
// against that PrizePicks flavor's line only.
const trends = await client.getPlayerTrends("baseball_mlb", "Aaron Judge", {
  market: "batter_total_bases",
});

for (const m of trends.markets) {
  console.log(
    `${m.market}: recent line ${m.recent_line}, avg ${m.avg_actual} ` +
      `(last 10 over ${m.last_10?.over_pct ?? "-"}%, ` +
      `streak ${m.current_streak?.length ?? 0} ${m.current_streak?.result ?? ""})`
  );
}
// Output: "batter_total_bases: recent line 1.5, avg 2.02 (last 10 over 30%, streak 2 under)"

Cross-book +EV (Pro)

// Find +EV plays on a single event. A sharp book anchors the no-vig
// fair line; every other book's price gets an EV%, with +EV plays
// floated to the top of each line group.
//
// `bookmakers` narrows the PRICES to books you hold accounts at — never
// the anchor. This still measures DK and FD against Pinnacle. Read
// line.fair_source for the book that anchored each line; the anchor is
// chosen per line, so one response mixes several.
const ev = await client.getEventEv("baseball_mlb", 12345, {
  markets: ["pitcher_strikeouts", "batter_hits"],
  bookmakers: ["draftkings", "fanduel"], // optional
  devig: "shin", // optional: "multiplicative" (default) or "shin"
});
// `devig` picks how the anchor's vig is removed. Shin's method loads the
// overround onto the longshot, correcting the favourite-longshot bias on
// props like anytime TD; the response echoes it as ev.devig_method.

for (const line of ev.lines) {
  const plus = line.outcomes.filter((o) => o.is_plus_ev);
  if (plus.length === 0) continue;
  console.log(
    `\n${line.market_key} ${line.description} ` +
      `line=${line.point} fair=${line.fair_source}`
  );
  for (const o of plus) {
    console.log(
      `  ${o.book_title.padEnd(11)} ${o.name.padEnd(6)} ` +
        `${o.price >= 0 ? "+" : ""}${o.price}  ev=+${o.ev_pct}%`
    );
  }
}

Market-implied projections (Hobby+)

// The statistical value the market implies per (market, player) — the
// line where the no-vig P(over) crosses 50%, median across books.
// Market-implied arithmetic, not a forecast. Use it to validate your
// own projections against the live market.
const proj = await client.getEventProjections("football_nfl", 25070, {
  markets: ["player_pass_yds", "player_receptions"], // optional
});

for (const row of proj.projections) {
  console.log(
    `${row.player.padEnd(24)} ${row.market_key.padEnd(22)} ` +
      `proj=${row.projected_value}  books=${row.books_contributing}`
  );
}

Best line — cross-book line shopping (Hobby+)

// You've decided the bet — now find which book pays the most.
const bl = await client.getEventBestLine("baseball_mlb", 12345, {
  markets: "pitcher_strikeouts",
  bookmakers: ["draftkings", "fanduel", "bovada"], // only my books
});

for (const line of bl.lines) {
  for (const [side, info] of Object.entries(line.sides)) {
    console.log(
      `${line.description} ${side} ${line.point}: ` +
      `${info.best.price} @ ${info.best.book_title} ` +
      `(of ${info.all_prices.length} books)`
    );
  }
}

DFS pick'em books (PrizePicks, Sleeper, Dabble) are excluded — their quotes aren't independently bettable payouts; Underdog is included only at clean two-way lines. Each price carries last_update so you can discount stale quotes.

Bulk CSV export of resolved props (Pro)

// Save every resolved MLB strikeout prop since April 1st to disk.
await client.exportResolvedProps({
  sport: "baseball_mlb",
  market: "pitcher_strikeouts",
  since: "2026-04-01T00:00:00Z",
  outPath: "./mlb-strikeouts.csv",
});

// Or load into memory.
const csv = await client.exportResolvedProps({ sport: "baseball_mlb" });
console.log(`got ${csv.byteLength} bytes of CSV`);

Full line-movement history (Historical Backfill / Enterprise)

// Every recorded snapshot (price + line, per book) — not just the close.
// The raw tick history no subscription tier can bulk-pull; exclusive to
// the one-time Historical Backfill pass and Enterprise. Page month by
// month — a full archive runs to gigabytes per sport.
await client.exportOddsHistory({
  sport: "baseball_mlb",
  since: "2026-04-01T00:00:00Z",
  until: "2026-05-01T00:00:00Z",
  outPath: "./mlb-line-history-apr.csv",
});

Webhooks (Streaming tier)

The Streaming tiers push line_movement, resolution, steam and market_suspended events to your URL in real time, with HMAC-SHA256 signing and automatic retries.

Register a subscription

const wh = await client.createWebhook({
  url: "https://example.com/hooks/propline",
  filterSportKey: "baseball_mlb",
  filterMarketKey: "pitcher_strikeouts",
  minPriceChangePct: 2.0, // only fire on shifts of 2%+ (or any point change)
  batchMax: 100,          // recommended: up to 100 events per POST
});

// Store wh.secret — this is the ONLY time it's returned.
const SECRET = wh.secret;
console.log(`webhook id: ${wh.id}`);

With batchMax set (1–500), events arrive as a signed envelope instead of one POST each: {"batch": true, "event_type": ..., "count": N, "events": [{"delivery_id": ..., "data": <per-event payload>}, ...]} with an X-PropLine-Batch: N header. Dedupe on each element's delivery_id. Use it for any high-volume subscription — sport-wide line_movement can exceed 1,000 events/min during a full slate, and one POST per event caps your delivery rate at your endpoint's response time. batchMax: 0 reverts to per-event delivery. JSON format only (Discord stays per-event).

Verify incoming deliveries

Each POST carries these headers:

| Header | Purpose | |--------|---------| | X-PropLine-Event | line_movement, resolution, steam, market_suspended, or test | | X-PropLine-Timestamp | Unix seconds | | X-PropLine-Signature | HMAC-SHA256 over ${timestamp}. + body | | X-PropLine-Delivery | Stable delivery id (use for idempotency) | | X-PropLine-Sequence | Your subscription's own event counter (use for replay) |

import express from "express";
import { PropLine } from "propline";

const app = express();
app.post(
  "/hooks/propline",
  express.raw({ type: "*/*" }),
  (req, res) => {
    const ok = PropLine.verifySignature({
      secret: process.env.WEBHOOK_SECRET!,
      timestamp: req.header("X-PropLine-Timestamp")!,
      body: req.body, // raw Buffer — make sure to use express.raw, not express.json
      signature: req.header("X-PropLine-Signature")!,
    });
    if (!ok) return res.status(401).end();
    // process payload (parse JSON yourself after verification)
    res.status(200).end();
  }
);

Line-movement payload

{
  "event_type": "line_movement",
  "sport_key": "baseball_mlb",
  "event": { "id": 5070, "home_team": "Seattle Mariners", "away_team": "Texas Rangers" },
  "market_key": "totals",
  "market_description": "Total 7.5",
  "outcome_id": 3860927178,
  "book_outcome_id": null,
  "player_id": null,
  "player_name": null,
  "outcome_name": "Over",
  "dfs_odds_type": null,
  "payout_multiplier": null,
  "previous": { "price_american": -750, "point": 7.0 },
  "current":  { "price_american": -300, "point": 7.5 },
  "price_change_pct": 60.0,
  "timestamp": "2026-04-18T03:49:00Z"
}

Resolution payload

{
  "event_type": "resolution",
  "sport_key": "baseball_mlb",
  "event": { "id": 16, "home_score": 4, "away_score": 2, "status": "final" },
  "market_key": "pitcher_strikeouts",
  "market_description": "Total Pitching Strikeouts",
  "outcome_id": 2210033841,
  "book_outcome_id": null,
  "player_id": "mlb:669373",
  "player_name": "Tarik Skubal (DET)",
  "outcome_name": "Over",
  "dfs_odds_type": null,
  "payout_multiplier": null,
  "point": 6.5,
  "resolution": "won",
  "actual_value": 9.0,
  "resolved_at": "2026-04-18T06:14:22Z"
}

outcome_id, book_outcome_id and player_id are the REST join keys (added 2026-09-02): outcome_id is PropLine's canonical outcome row id — the same value /odds?includeBookIds=true returns as outcome_id — so a delivery pins to exactly one REST row without matching on name, side or line. It is globally unique across books and sides and stable across price and point changes for the same (market, side, player); books whose alt ladders put the line in the market description (PrizePicks goblin/demon, ProphetX, Fanatics, Marathon) get a new market and a new id when that line moves. book_outcome_id and player_id have the same semantics as on /odds (null when the book publishes no id / when the player is unconfirmed).

market_description is where a DFS alt market's flavor + line live (e.g. PrizePicks "Rebounds (demon 12.5)"). dfs_odds_type is the PrizePicks flavor (standard / goblin / demon; null for every traditional book); payout_multiplier is Underdog's numeric boost/discount (PrizePicks publishes no numeric multiplier — the flavor is the signal). Same semantics as the identically-named fields on /odds outcomes.

Market-suspended payload

Stale prices on a live game (pregame_only)

Each bookmaker block in /odds carries pregame_only. It is true when the event is live and that book does not price it in play — the prices shown are its last pregame quote and will not move again until the game ends.

This is the one staleness case suspended_at cannot show you: that flag is set when a book pulls a market, and a book with no in-play feed is never polled for the fixture once it starts, so nothing goes missing and nothing is flagged.

const odds = await client.getEventOdds("football_ncaaf", eventId);
const live = odds.bookmakers.filter((b) => !b.pregame_only);

The rows are still returned rather than withheld, because on the DFS books that frozen pregame line is the number the bet settles against — so treat pregame_only: true as "a real price, but not a live one".

A book took a market off the board pregame. One delivery per (book, event, player) — a late scratch is ONE event carrying every key the book pulled, not one per key. books_agreeing is how many books have pulled the same subject on the same event; subscribe with minBooksAgreeing: 3 to hear only corroborated drops, or leave it unset to hear every one (the right choice if you price off a single book). Pull-side twin: suspended_at on every market in /odds, on every tier.

await client.createWebhook({
  url: "https://example.com/hooks/propline",
  events: ["market_suspended"],
  filterSportKey: "baseball_mlb",
  minBooksAgreeing: 3, // omit to receive every single-book drop
});
{
  "event_type": "market_suspended",
  "sport_key": "baseball_mlb",
  "event": { "id": 138811, "home_team": "Pittsburgh Pirates", "away_team": "Boston Red Sox" },
  "bookmaker_key": "draftkings",
  "bookmaker_title": "DraftKings",
  "subject": "Willson Contreras",
  "reason": "off_the_board",
  "markets": [
    { "key": "batter_hits", "description": "Willson Contreras Hits O/U", "period": null,
      "last_seen": "2026-08-16T14:03:45+00:00",
      "last_price": [{ "name": "Over", "price": -115, "point": 0.5 },
                     { "name": "Under", "price": -105, "point": 0.5 }] },
    { "key": "batter_total_bases" }
  ],
  "books_agreeing": 7,
  "books": ["betmgm", "betrivers", "draftkings", "novig", "pinnacle", "prophetx", "underdog"],
  "suspended_at": "2026-08-16T14:07:30+00:00"
}

reason is "off_the_board" for a sportsbook and "no_offers" for an exchange whose resting offers went. There is no restore event: when the market returns, line_movement fires on the returning price.

Manage subscriptions

for (const wh of await client.listWebhooks()) {
  console.log(wh.id, wh.url, wh.active ? "active" : "paused");
}

await client.updateWebhook(whId, { minPriceChangePct: 5.0 });
await client.testWebhook(whId);
await client.listWebhookDeliveries(whId, { limit: 50 });
// Page backwards through a deep queue: pass the smallest id from the
// previous page. Newest-first; a short page is the last one.
await client.listWebhookDeliveries(whId, { limit: 200, beforeId: 123456 });
await client.deleteWebhook(whId);

Catching up after an outage

Every delivery carries X-PropLine-Sequence — a counter monotonic within your subscription. Store the highest one you processed, then read forward from it. Do not use X-PropLine-Delivery as the cursor: that id is global across all subscriptions, so its gaps are other customers' traffic.

let cursor = await loadMyCursor(); // highest X-PropLine-Sequence processed

for (;;) {
  const page = await client.replayWebhookEvents(whId, { sinceSeq: cursor, limit: 100 });

  if (page.truncated) {
    // Events after your cursor aged out of retention and are gone.
    // Resync from the REST endpoints rather than assume you are current.
    await resyncFromRest();
  }

  for (const ev of page.events) {   // oldest first
    await handle(ev.event_type, ev.data);
  }

  cursor = page.next_seq;
  await saveMyCursor(cursor);
  if (!page.has_more) {
    console.log(`behind by ${page.latest_seq - cursor} events`);
    break;
  }
}

Websocket streaming

If your stack already speaks websockets — or you can't host a public HTTPS endpoint — connect a socket instead of receiving POSTs. Same events, same filters, same seq: a stream and a webhook are the same subscription with a different transport.

const wh = await client.createWebhook({
  transport: "websocket",          // no url — there is nowhere to POST
  events: ["line_movement"],
  filterSportKey: "baseball_mlb",
});

for await (const ev of client.stream({ webhookId: wh.id, sinceSeq: myCursor })) {
  await handle(ev.event_type, ev.data);
  myCursor = ev.seq;               // persist it; this is your resume point
}

Reconnects and resumes from the last seq automatically, so a dropped connection is not a gap in your data. onTruncated fires when events after your cursor aged out of retention — the one case streaming cannot make you whole, where you should resync from REST. Zero dependencies: it uses Node's built-in WebSocket (Node 22+).

Concurrent connections are capped per plan (Streaming Lite 2, Streaming 5). Delivered events are not metered.

Replay is bounded by delivery retention: 2 days, and at most 5,000 deliveries per subscription. latest_seq is not subject to retention, so latest_seq - next_seq stays honest even after the rows are pruned. Sequence numbers always increase and never repeat but are not guaranteed to be dense — treat a skipped number as normal, and read truncated for real loss. Neither replayWebhookEvents nor listWebhookDeliveries counts against your daily quota.

Error handling

import { PropLine, AuthError, RateLimitError, PropLineError } from "propline";

const client = new PropLine("your_api_key");

try {
  const odds = await client.getOdds("baseball_mlb", { eventId: 1 });
} catch (e) {
  if (e instanceof AuthError) {
    console.error("Invalid API key");
  } else if (e instanceof RateLimitError) {
    // Daily-cap 429s include a pre-filled one-click upgrade URL
    console.error(`Rate limited: ${e.detail}`);
    if (e.upgradeUrl) console.error(`Upgrade: ${e.upgradeUrl}`);
  } else if (e instanceof PropLineError) {
    console.error(`API error: ${e.statusCode} — ${e.detail}`);
  } else {
    throw e;
  }
}

Gated and throttled endpoints return a structured error body (docs), exposed on every PropLineError:

| Property | Meaning | |---|---| | errorCode | Stable machine-readable code: upgrade_required, daily_limit_exceeded, burst_limit_exceeded, missing_api_key, invalid_api_key (undefined on plain errors) | | detail | Human-readable sentence (also in e.message as [status] detail) | | upgradeUrl | Where to unlock a gated feature or lift a cap — pre-filled one-click URL on daily-cap 429s | | info | The full structured body (PropLineErrorInfo): required_tier, retry_after_seconds, docs_url, … |

Tracking your usage

Every authenticated response carries live quota headers; the client parses them into client.lastQuota automatically:

await client.getSports();

const q = client.lastQuota!;
console.log(`${q.used}/${q.limit} used today, ${q.remaining} left`);
console.log(`Quota resets at ${q.resetAt.toISOString()}`); // 00:00 UTC, hard reset

lastQuota is null before the first request and refreshes on every call (including 429s), so a long-running poller can watch remaining and back off before hitting the daily cap.

Links

License

MIT