NHL Odds API: Puck Lines, Totals & Player Props (Python + Free Tier)
NHL Odds API: The Problem
The NHL doesn’t run a public odds API. DraftKings, FanDuel, BetMGM, and Caesars all carry NHL puck lines, totals, and player props — but none of them expose a developer-facing endpoint. Pinnacle has sharp NHL lines but shut down public API access for US retail. If you want to build anything around NHL odds — a line-shopping tool, an arb scanner, a player-prop model, a Closing Line Value tracker — your options have historically been:
- Scrape sportsbook websites — fragile, rate-limited, and increasingly blocked by Cloudflare.
- Pay enterprise feeds — Sportradar and Betgenius quote $3,000+/month for real-time hockey.
- Use a generic odds API — most cap out at 20–40 books and paywall player props and historical data.
There’s a third option. OddsPapi aggregates NHL odds from 350+ bookmakers — including Pinnacle, Bet365, DraftKings, FanDuel, BetMGM, Caesars, BetRivers, and the full Betfair Exchange — on a free developer tier. This tutorial shows you exactly how to pull live moneylines, puck lines, totals, and player props in Python, with real API responses from a verified NHL game.
Old Way vs OddsPapi
| Problem | Scraping / Enterprise | OddsPapi |
|---|---|---|
| NHL coverage | Variable — depends on each book’s scraping defenses | 112 books on a single game (verified) |
| Sharp lines (Pinnacle, Bet365) | Pinnacle API is closed; Bet365 blocks scrapers | Included on the free tier |
| Player props | Paywalled on most APIs | Goals, Assists, Points, Shots on Goal — all native |
| Historical puck lines | Enterprise-only ($3k+/month) | Free tier includes /historical-odds |
| Rate limits | Get IP-banned after a few hundred requests | ~0.88s cooldown, no bans |
| Setup time | Weeks of scraper engineering | 10 minutes |
Step 1: Authenticate
OddsPapi uses a query-parameter API key, not a header. Grab a free key from the dashboard, then:
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
# Smoke test
r = requests.get(f"{BASE_URL}/sports", params={"apiKey": API_KEY})
print(r.status_code) # 200
nhl = [s for s in r.json() if s["sportId"] == 15]
print(nhl)
# [{'sportId': 15, 'slug': 'ice-hockey', 'sportName': 'Ice Hockey'}]
Every call needs apiKey as a query parameter. Ice hockey is sportId=15. NHL is one tournament within that sport — we’ll filter for it in Step 2.
Step 2: Fetch NHL Fixtures
The /fixtures endpoint returns every ice hockey game in a date range (max 10 days). Each fixture includes tournamentName and categoryName, so you can filter to NHL games only:
r = requests.get(
f"{BASE_URL}/fixtures",
params={
"apiKey": API_KEY,
"sportId": 15,
"from": "2026-04-18",
"to": "2026-04-28",
},
)
fixtures = r.json()
nhl = [
f for f in fixtures
if f.get("tournamentName") == "NHL" and f.get("hasOdds")
]
print(f"NHL games with odds: {len(nhl)}")
for f in nhl[:5]:
print(f"{f['participant1Name']:<25} vs {f['participant2Name']:<25} "
f"{f['startTime']} ({f['fixtureId']})")
Sample output:
NHL games with odds: 35
Carolina Hurricanes vs Ottawa Senators 2026-04-18T19:00:00.000Z (id1500087370712566)
Dallas Stars vs Minnesota Wild 2026-04-18T21:30:00.000Z (id1500054070718042)
Edmonton Oilers vs Los Angeles Kings 2026-04-18T22:00:00.000Z (...)
Two fields matter for filtering:
tournamentName == "NHL"— excludes AHL, KHL, international games, and college hockeyhasOdds == True— the fixture has at least one bookmaker price (otherwise/oddsreturns metadata only)
Step 3: Pull Live Moneylines
The /odds endpoint returns the current price from every bookmaker carrying the game. The response is deeply nested — bookmaker → market → outcome → players[“0”] → price. Here’s how to extract NHL moneylines across all books:
fixture_id = "id1500023470558100" # Dallas vs Minnesota
r = requests.get(
f"{BASE_URL}/odds",
params={"apiKey": API_KEY, "fixtureId": fixture_id},
)
data = r.json()
books = data["bookmakerOdds"]
print(f"Books with odds: {len(books)}")
# Market 151 = "Winner (incl. overtime and penalties)" — NHL moneyline
# Outcome 151 = Home (participant1), 152 = Away (participant2)
MONEYLINE = "151"
print(f"{'Book':<18} {'Home':<10} {'Away':<10}")
for slug, book in sorted(books.items()):
m = book.get("markets", {}).get(MONEYLINE, {})
outs = m.get("outcomes", {})
home = outs.get("151", {}).get("players", {}).get("0", {}).get("price")
away = outs.get("152", {}).get("players", {}).get("0", {}).get("price")
if home and away:
print(f"{slug:<18} {home:<10} {away:<10}")
Live prices pulled just now for Dallas Stars vs Minnesota Wild:
| Bookmaker | Dallas (Home) | Minnesota (Away) |
|---|---|---|
| pinnacle | 1.847 | 2.07 |
| bet365 | 1.83 | 2.00 |
| draftkings | 1.83 | 2.00 |
| fanduel | 1.83 | 2.00 |
| caesars | 1.833 | 2.00 |
| betrivers | 1.82 | 2.07 |
| bovada.lv | 1.82 | 2.02 |
| betfair-ex | 1.88 | 2.10 |
Note the line-shopping edge: Betfair Exchange is paying 1.88 on Dallas vs 1.82 at BetRivers — a 3.3% pricing gap on the same game. Running this query in a loop across your book list is the foundation of any value-betting system.
Pinnacle sits between the US retail books (1.83) and the exchange (1.88). That’s the classic signature of a sharp market versus a retail book carrying a shaded line.
Step 4: Puck Lines (±1.5 Goals)
The NHL standard puck line is ±1.5 goals, including overtime and shootout. OddsPapi uses two market IDs for the two sides:
15228— Handicap -1.5 (favorite giving 1.5 goals)15240— Handicap +1.5 (underdog getting 1.5 goals)
# Puck line -1.5 (favorite) and +1.5 (underdog)
for slug, book in books.items():
fav = book.get("markets", {}).get("15228", {}).get("outcomes", {}).get(
"15228", {}).get("players", {}).get("0", {}).get("price")
dog = book.get("markets", {}).get("15240", {}).get("outcomes", {}).get(
"15241", {}).get("players", {}).get("0", {}).get("price")
if fav or dog:
print(f"{slug:<18} DAL -1.5: {fav} MIN +1.5: {dog}")
# Sample output:
# pinnacle DAL -1.5: 3.13 MIN +1.5: 3.6
# bovada.lv DAL -1.5: 3.1 MIN +1.5: 1.4
Puck lines have much thinner coverage than moneylines — only Pinnacle, Bovada, and a handful of others carried this game’s ±1.5 at query time. If you’re building a puck-line arb scanner, filter your book list to the ones that actually reliably post this market.
Step 5: Totals (Over/Under)
NHL totals (incl. overtime) live under the Total (incl. overtime and penalties) market. Each line is its own market ID:
| Line | Market ID | Over Outcome | Under Outcome |
|---|---|---|---|
| 5.5 | 15174 | 15174 | 15175 |
| 6.0 | 15176 | 15176 | 15177 |
| 6.5 | 15178 | 15178 | 15179 |
TOTALS = {
5.5: (15174, 15174, 15175),
6.0: (15176, 15176, 15177),
6.5: (15178, 15178, 15179),
}
for slug in ["pinnacle", "caesars", "draftkings"]:
b = books.get(slug, {})
print(f"\n{slug}:")
for line, (mid, over_oid, under_oid) in TOTALS.items():
m = b.get("markets", {}).get(str(mid), {})
over = m.get("outcomes", {}).get(str(over_oid), {}).get(
"players", {}).get("0", {}).get("price")
under = m.get("outcomes", {}).get(str(under_oid), {}).get(
"players", {}).get("0", {}).get("price")
if over and under:
print(f" O/U {line}: {over} / {under}")
# Sample output:
# pinnacle:
# O/U 5.5: 1.854 / 2.05
# O/U 6.0: 2.04 / 1.819
# O/U 6.5: 2.27 / 1.671
# caesars:
# O/U 6.0: 2.0 / 1.769
# O/U 6.5: 2.2 / 1.667
Pinnacle’s middle total is 6.0 pricing roughly even juice (2.04 / 1.819), which tells you the model thinks this is a ~6-goal game. Caesars agrees on the number but slightly different juice. If you’re a totals bettor, Pinnacle is your benchmark — deviations from its line signal where the soft books are off.
Step 6: Player Props
This is where most generic odds APIs fall off a cliff. OddsPapi returns NHL player props natively — Goals, Assists, Points, Shots on Goal, Blocks, Saves, Power Play Points — with player names embedded in the response.
Player prop markets use the players dict differently: each player is their own key (not just "0"):
# Market 15140 = Over Under Player Shots On Goal, line 2.5 (incl. overtime)
# Outcome 15140 = Over, 15141 = Under
SHOTS_OVER_25 = "15140"
pin = books["pinnacle"]["markets"][SHOTS_OVER_25]["outcomes"]
print(f"{'Player':<28} {'Over 2.5':<10} {'Under 2.5':<10}")
over_players = pin["15140"]["players"] # dict of {player_id: {...}}
under_players = pin["15141"]["players"]
for pid, over in over_players.items():
if not over.get("active"):
continue
under = under_players.get(pid, {})
name = over.get("playerName", "?")
print(f"{name:<28} {over['price']:<10} {under.get('price', '-'):<10}")
Live Pinnacle shots-on-goal props for Dallas-Minnesota:
| Player | Over 2.5 SOG | Under 2.5 SOG |
|---|---|---|
| Eriksson Ek, Joel | 1.833 | 1.909 |
| Hartman, Ryan | 2.17 | 1.641 |
| Rantanen, Mikko | 2.06 | 1.709 |
| Boldy, Matt | 1.54 | 2.37 |
| Johnston, Wyatt | 1.769 | 1.98 |
Core NHL player-prop markets worth hardcoding:
| Market ID | Description | Handicap |
|---|---|---|
| 15124 | Goals — Anytime Goalscorer | 0.5 |
| 15118 | Assists | 0.5 |
| 15130 | Points (G + A) | 0.5 |
| 15138 | Shots on Goal | 1.5 |
| 15140 | Shots on Goal | 2.5 |
| 15142 | Shots on Goal | 3.5 |
Don’t hardcode everything. The full NHL market catalog lives at /v4/markets?sportId=15 (filter the response to sportId == 15 — the endpoint returns cross-sport data by default):
r = requests.get(f"{BASE_URL}/markets", params={"apiKey": API_KEY, "sportId": 15})
cat = [m for m in r.json() if m["sportId"] == 15]
print(f"Total NHL markets: {len(cat)}")
# Total NHL markets: 745
# Build a lookup for any market ID you see in the odds response
market_names = {
m["marketId"]: f"{m['marketName']} (hcap={m['handicap']})"
for m in cat
}
# Look up an unknown market ID from the odds response
print(market_names[15140])
# 'Over Under Player Shots On Goal (incl. overtime) (hcap=2.5)'
Step 7: Find the Best NHL Line Across 112 Books
Put it all together — a simple best-price scanner across every book carrying the NHL moneyline:
def best_price(books, market_id, outcome_id):
best_book, best_price = None, 0
for slug, b in books.items():
p = (b.get("markets", {})
.get(str(market_id), {})
.get("outcomes", {})
.get(str(outcome_id), {})
.get("players", {})
.get("0", {})
.get("price"))
if p and p > best_price:
best_book, best_price = slug, p
return best_book, best_price
# Best Dallas moneyline across all 112 books
home_book, home_odds = best_price(books, 151, 151)
away_book, away_odds = best_price(books, 151, 152)
print(f"Best Dallas: {home_odds} at {home_book}")
print(f"Best Minnesota: {away_odds} at {away_book}")
# Sample output:
# Best Dallas: 1.88 at betfair-ex
# Best Minnesota: 2.10 at betfair-ex
Now check for an arb. Implied probability = 1 / decimal_odds. If the sum of the two sides’ best-price implied probs is below 1.0, you have a risk-free opportunity:
arb = (1 / home_odds) + (1 / away_odds)
print(f"Book sum: {arb:.4f}")
# Book sum: 1.0083 → 0.83% overround, not an arb but tight
For a full arb scanner with multi-outcome staking math, see our dedicated Python arbitrage bot tutorial.
Historical NHL Odds (Free)
Every sharp bettor needs Closing Line Value. OddsPapi’s /historical-odds endpoint returns the full price history for any NHL fixture, free. The response shape differs from live odds — players["0"] is a list of snapshots, not a dict:
r = requests.get(
f"{BASE_URL}/historical-odds",
params={
"apiKey": API_KEY,
"fixtureId": fixture_id,
"bookmakers": "pinnacle,bet365,singbet", # max 3 per call
},
)
hist = r.json()
# Walk the price history for the Dallas moneyline at Pinnacle
for snap in hist["bookmakers"]["pinnacle"]["markets"]["151"]["outcomes"]["151"]["players"]["0"]:
print(f"{snap['createdAt']} {snap['price']}")
# Sample output:
# 2026-04-17T14:12:03+00:00 1.92
# 2026-04-17T18:30:41+00:00 1.89
# 2026-04-18T09:02:15+00:00 1.85
# 2026-04-18T14:07:06+00:00 1.847 ← current
That’s steam money moving the Dallas line from 1.92 to 1.85 in 24 hours — exactly the kind of signal a line-movement alert bot looks for. For a full steam-move detector, see the steam move detector post.
Rate Limits & Production Notes
- Cooldown: ~0.88s between calls to the same endpoint. Use
time.sleep(0.2)between iterations as a safety buffer. - Date range:
/fixturesaccepts a max 10-day window. Loop across windows for season-long backfills. - Historical bookmakers:
/historical-oddscaps at 3 books per call. Loop with different combinations if you need more. - Active outcomes: check
outcome.active == True— suspended markets ship withactive: falseand stale prices. - Exchange odds: Betfair Exchange and Polymarket are included. Their payloads add an
exchangeMetablock with the lay side. Parse it defensively — the shape varies by book.
Why This Beats the Alternative
Every other NHL odds option forces a tradeoff:
- The Odds API: 20 bookmakers, $119/mo for 5M calls, no player props on the base plan.
- Sportradar: Enterprise-only, $3k+/month, 6-week contract cycle.
- SportsGameOdds: 85 books, paywalls historical data, no sharp-book depth.
- Scraping DraftKings directly: IP bans within hours, TOS violation.
OddsPapi is the only option that gives you Pinnacle + Bet365 + DraftKings + FanDuel + Betfair Exchange + 107 other books on one endpoint, on a free tier, with free historical data. That’s the pitch.
Stop Scraping. Start Building.
Grab a free API key and you’re pulling NHL puck lines, totals, and shots-on-goal props in the next 10 minutes. Historical data is included — backtest your model before you risk a dollar.
FAQ
Is there an official NHL odds API?
No. The NHL licenses official data partnerships for sports integrity and betting operators, but does not expose a public developer API for odds. OddsPapi aggregates odds from 350+ bookmakers covering every NHL regular-season and playoff game.
Does OddsPapi include DraftKings and FanDuel NHL odds?
Yes. Both DraftKings and FanDuel are covered on every NHL fixture we checked, alongside BetMGM, Caesars, BetRivers, Bovada, and the Betfair Exchange. Sharp books (Pinnacle, Bet365, Singbet) are also included on the free tier.
Are NHL player props available?
Yes. Goals, Assists, Points, Shots on Goal, Blocks, Power Play Points, Saves, and Goals Against are all native markets. Player names are embedded in the response — no extra lookup required.
Is historical NHL odds data free?
Yes. The /historical-odds endpoint is on the free tier. Every price change is timestamped, so you can compute Closing Line Value, track steam moves, or backtest any strategy against real NHL line history.
What’s the rate limit?
Approximately 0.88 seconds between calls to the same endpoint on the free tier. For production arb scanners or line monitors, OddsPapi also offers WebSocket push so you don’t have to poll.