NHL API: Free Endpoints for Schedules, Scores, Play-by-Play and Stats
The free JSON feed behind nhl.com and the NHL app. No key needed, and every endpoint below answered.
Last updated:
What is the NHL API?
The NHL API is the free, undocumented JSON feed behind nhl.com and the NHL app. The league runs it, publishes no docs for outside developers and asks for no API key: a plain GET request returns JSON. api-web.nhle.com serves schedules, scores, standings, rosters, players and game feeds, and api.nhle.com/stats/rest answers filtered stat queries.
We called every URL on this page on 2 November 2026. For several leagues behind one URL pattern, the ESPN API serves the NHL under hockey/nhl. Its scoreboard for 1 November returned the same five games under other ids. Edmonton at the Rangers is 401892627 at ESPN and 2026020230 here, so join the two feeds on date and team codes.
NHL API Base URLs
Two hosts answered in our tests. The third, the old stats API, is gone.
| Base URL | What it serves | Status |
|---|---|---|
https://api-web.nhle.com/v1 | Schedules, scores, standings, rosters, players, box scores, play-by-play | 200 |
https://api.nhle.com/stats/rest/en | Stat tables with filters, sorting and paging | 200 |
https://statsapi.web.nhl.com/api/v1 | The old stats API | No DNS record, no HTTP status |
For https://statsapi.web.nhl.com/api/v1/teams, curl failed with "Could not resolve host", and a public DNS resolver returned NXDOMAIN (no such domain). No server answers there, so retries will never succeed. The bare roots of both live hosts return 404, and neither serves docs.
What the NHL API Covers
The feed covers the NHL only, from the preseason to the playoffs. Every game carries a gameType: 1 for preseason, 2 for regular season, 3 for playoffs. Toronto's 2026-27 schedule lists 84 regular-season games, and the stats host counts 1,344 league-wide. The final 2025-26 standings show 82 games per team, so code that hard-codes 82 needs a fix.
/v1/standings-season lists 109 seasons, back to 1917-18, and a play-by-play call for game 2010020001 (Montreal at Toronto, 7 October 2010) returned 305 plays. Names arrive as objects keyed by language, such as {"default": "Maple Leafs"}, so read .default unless you want a translation.
Key NHL API Endpoints
Every api-web.nhle.com path starts with /v1/. Dates are YYYY-MM-DD, seasons are eight digits (20262027) and teams are three-letter codes such as TOR. The examples show what each call returned for us.
| Endpoint | What it returns | Example |
|---|---|---|
/v1/schedule/{date} | League schedule for seven days from the date | /v1/schedule/2026-11-02: 51 games |
/v1/score/{date} | Scores, goals and clocks for one day | /v1/score/2026-11-01: 5 games |
/v1/standings/{date} | League table on a date | /v1/standings/2026-11-01: 32 teams |
/v1/standings-season | Every season and its standings rules | 109 seasons |
/v1/gamecenter/{gameId}/boxscore | Team totals and player stat lines | /v1/gamecenter/2026020224/boxscore |
/v1/gamecenter/{gameId}/play-by-play | Every event of a game | 2026020224: 308 plays |
/v1/gamecenter/{gameId}/landing | Scoring summary, three stars, penalties | /v1/gamecenter/2026020224/landing |
/v1/club-schedule-season/{team}/{season} | One team's season | TOR/20262027: 88 games |
/v1/club-schedule/{team}/week/{date} | One team's week | TOR/week/2026-11-02: 3 games |
/v1/roster/{team}/{season} | forwards, defensemen, goalies | TOR/20262027: 16, 10, 2 |
/v1/player/{playerId}/landing | Bio, draft, season and career totals, last 5 games | 8478402 (Connor McDavid) |
/v1/player/{playerId}/game-log/{season}/{gameType} | One row per game | 8478402/game-log/20262027/2: 15 games |
/v1/skater-stats-leaders/{season}/{gameType} | League leaders by category | ?categories=goals&limit=5 |
/v1/club-stats/{team}/{season}/{gameType} | A team's skater and goalie stats | TOR/20262027/2: 24 skaters |
One key differs: the box score groups players under defense, and the roster uses defensemen.
Stats REST endpoints
The stats host wraps every answer in {"data": [...], "total": N}, and total is the full match count whatever limit you send.
| Endpoint | What it returns | Example |
|---|---|---|
/stats/rest/en/team | Every team in the records, defunct ones included | 62 rows, the Quebec Nordiques first |
/stats/rest/en/skater/summary | One row per skater: goals, points, ice time | ?limit=10&cayenneExp=seasonId=20262027: total 741 |
/stats/rest/en/game | One row per game with ids and scores | ?cayenneExp=season=20262027&limit=5: total 1409 |
NHL API Play-by-Play Endpoint
The play-by-play feed is the NHL endpoint developers search for most. It lists every faceoff, hit, shot, penalty and goal of one game, with rink coordinates and the players involved.
https://api-web.nhle.com/v1/gamecenter/{gameId}/play-by-play
Where the game id comes from
Every schedule, score and team schedule entry has a ten-digit id: the year the season starts, a two-digit game type and a four-digit game number. The stats host confirms the split. Game 2026020232, Vegas at Boston on 2 November 2026, has season 20262027, gameType 2 and gameNumber 232. A playoff game from April 2026 reads 2025030186, because it belongs to the 2025-26 season.
What a play contains
Our test game, 2026020224 (Toronto 4, Vancouver 2 on 31 October 2026), returned 308 plays and 40 rosterSpots. William Nylander's opening goal looked like this, trimmed:
{"eventId": 66,
"periodDescriptor": {"number": 1, "periodType": "REG"},
"timeInPeriod": "01:23", "timeRemaining": "18:37",
"situationCode": "1551", "homeTeamDefendingSide": "right",
"typeCode": 505, "typeDescKey": "goal", "sortOrder": 26,
"details": {"xCoord": 82, "yCoord": 4, "zoneCode": "O",
"shotType": "backhand", "scoringPlayerId": 8477939,
"assist1PlayerId": 8477426, "eventOwnerTeamId": 10,
"goalieInNetId": 8482447, "awayScore": 1, "homeScore": 0}}
| Field | Type | What it holds |
|---|---|---|
typeDescKey | string | Event type: 57 hit, 54 faceoff, 50 shot-on-goal and 6 goal in our game |
typeCode | number | The type as a number: 505 goal, 506 shot on goal |
sortOrder | number | Event order. Sort on this, because eventId is out of order (a shot at 01:01 has id 151, the goal at 01:23 has id 66) |
periodDescriptor.number | number | Period |
timeInPeriod | string | Elapsed clock as MM:SS |
details.xCoord, details.yCoord | number | Rink position: x from -98 to 99, y from -41 to 41 in our game |
homeTeamDefendingSide | string | left or right. It flipped every period, and Toronto's goals moved from positive x in period 1 to negative x in period 2 |
situationCode | string | Four digits for the players on the ice: 1551 on 248 of the 308 plays |
details | object | Player ids such as scoringPlayerId and shootingPlayerId. Absent on 7 plays |
Map the player ids in details to rosterSpots for names and positions. A live game adds a situation object, and for Vegas at Boston it paired code 1351 with an away strength of 3 and a home strength of 5.
Dates, Seasons and the now Redirect
Paths with a date or season also take now or current. All eight that we tried answered with HTTP 307, an empty body and a location header that holds the dated URL:
/v1/schedule/now -> /v1/schedule/2026-11-02
/v1/score/now -> /v1/score/2026-11-02
/v1/roster/TOR/current -> /v1/roster/TOR/20262027
/v1/club-stats/TOR/now -> /v1/club-stats/TOR/20262027/2
/v1/skater-stats-leaders/current?categories=goals&limit=5
-> /v1/skater-stats-leaders/20262027/2?categories=goals&limit=5
Your client has to follow redirects. Python requests and JavaScript fetch do it by default, and curl needs -L. Prefer the dated form in stored code, because it skips a round trip and always means the same day. The redirect uses the North American date: at 01:10 UTC on 3 November, /v1/schedule/now still went to 2 November. Any start date works for the schedule: /v1/schedule/2026-11-04, a Wednesday, returned 4 to 10 November. Past dates work too, and /v1/standings/2026-10-15 put Vegas first with 14 points.
| Parameter | What it does | Example |
|---|---|---|
categories | Stat names for the leaders endpoint. With none, it returned 9 categories | ?categories=goals,assists |
limit (leaders) | Rows per category. -1 returned all 481 skaters with a goal | ?limit=-1 |
{gameType} | Path segment: 2 regular season, 3 playoffs | /game-log/20252026/3: 6 games |
cayenneExp | Filter expression on the stats host | seasonId=20262027 and gameTypeId=2 |
sort | URL-encoded JSON list of sort keys | [{"property":"points","direction":"DESC"}] |
limit, start (stats) | Page size and offset | limit=5&start=5 |
Field names in cayenneExp change by endpoint. The game endpoint filters on season, and the skater summary needs seasonId: it returned HTTP 500 for season. Paging needs a tiebreak too. Sorted on points alone, the pages at start=0 and start=5 both listed Nathan MacKinnon (24 points) and left out Auston Matthews, who also had 24. A second sort key on playerId fixed it.
NHL API Code Examples
Scores for one day in Python
import requests
url = 'https://api-web.nhle.com/v1/score/2026-11-01'
resp = requests.get(url, timeout=10)
resp.raise_for_status()
for game in resp.json()['games']:
away, home = game['awayTeam'], game['homeTeam']
print(game['id'], away['abbrev'], away['score'], '@', home['abbrev'], home['score'], game['gameState'])
The first line it printed was 2026020227 WPG 0 @ CAR 7 OFF.
Goals from the play-by-play in Python
import requests
url = 'https://api-web.nhle.com/v1/gamecenter/2026020224/play-by-play'
data = requests.get(url, timeout=10).json()
names = {p['playerId']: p['lastName']['default'] for p in data['rosterSpots']}
plays = sorted(data['plays'], key=lambda p: p['sortOrder'])
for play in plays:
if play['typeDescKey'] == 'goal':
d = play['details']
print(play['periodDescriptor']['number'], play['timeInPeriod'],
names[d['scoringPlayerId']], d['shotType'], f"{d['awayScore']}-{d['homeScore']}")
It printed six goals, from 1 01:23 Nylander backhand 1-0 to 3 18:24 Blueger wrist 4-2.
Standings by date with cURL
curl -s 'https://api-web.nhle.com/v1/standings/2026-11-01' \
| jq -r '.standings[:3][] | "\(.teamName.default) \(.points) pts in \(.gamesPlayed) GP"'
# now answers 307 first; -L follows it to the dated URL
curl -sL 'https://api-web.nhle.com/v1/standings/now' | jq '.standings[0].points'
The first command printed Carolina Hurricanes 26 pts in 16 GP on its first line. Drop the jq part to see the raw JSON.
Points leaders in JavaScript
const sort = JSON.stringify([
{ property: 'points', direction: 'DESC' },
{ property: 'playerId', direction: 'ASC' },
]);
const url = 'https://api.nhle.com/stats/rest/en/skater/summary?limit=5'
+ '&cayenneExp=' + encodeURIComponent('seasonId=20262027 and gameTypeId=2')
+ '&sort=' + encodeURIComponent(sort);
const res = await fetch(url);
const { data, total } = await res.json();
data.forEach(p => console.log(p.skaterFullName, p.teamAbbrevs, p.points));
console.log(total, 'skaters in total');
We ran it as an .mjs file on Node 22. It printed Connor McDavid EDM 39 first and 741 skaters in total last.
NHL API Errors: 404, 307 and 500
404 Not Found, as an HTML page
A game id that does not exist (/v1/gamecenter/2026029999/boxscore), an impossible date (/v1/schedule/2026-13-45) and a mistyped path (/v1/scores/now) all returned 404. The body is an HTML error page with content-type: text/html, so .json() raises an exception on it. Check the status code before you parse the body.
307, then 404, for a bad team code
/v1/roster/XYZ/current answered 307 to /v1/roster/XYZ/20262027, and that URL returned 404. The redirect comes before the server checks the team, so a 307 says nothing about whether a code is valid.
500 from the stats host
A wrong field name in cayenneExp returns HTTP 500 with {"message":"Exception processing Agrest request"}. We got it from /stats/rest/en/skater/summary?limit=5&cayenneExp=season=20262027.
200 with no stats yet
A scheduled game's box score answers 200 with gameState FUT and no playerByGameStats key. Finished games read OFF or FINAL: Toronto's preseason games read FINAL and its 16 finished regular-season games read OFF, so treat both as final.
Limitations and Terms
Personal, non-commercial use only
The NHL publishes no separate API terms. Its Terms of Service (last updated 29 October 2025) cover nhl.com and the NHL apps, which run on this feed. Section 7 allows use "only for non-commercial, informational, personal use". Section 2 forbids "unauthorized spidering, scraping, or harvesting of content or information". A personal dashboard or a class project fits those words. A paid app, a resold feed or a bulk download of every game does not.
- No published rate limit: the terms and the responses state none. We made about 60 requests in 20 minutes and got no 429 and no rate-limit headers. Responses from api-web carried
cache-control: max-agevalues of up to 19 seconds, so poll a live game no faster than that. - No docs or changelog: the NHL documents none of these paths, and the old host has already disappeared.
- robots.txt: both hosts allow all user agents, block a list of named AI crawlers and set
ai-train=no. - Odds: schedule and score responses carry an
oddsPartnerslist and, for a game about to start, anoddsarray per team. On the evening of 2 November, only Calgary at San Jose, the one game still to start, had prices. For spreads, totals and props across many books, see NHL odds APIs. - Shot data for models: MoneyPuck publishes per-season NHL shot files with expected-goals values, listed on our free sports datasets page.
When your app needs an SLA, a commercial license or support when a field changes, compare licensed sports data providers.
Community Resources
We fetched each of these on 2 November 2026.
- Zmalski/NHL-API-Reference on GitHub: an unofficial endpoint reference for both hosts, with curl examples and parameter notes. MIT license; last push November 2025.
- nhl-api-py on PyPI: a Python wrapper (
pip install nhl-api-py, thenfrom nhlpy import NHLClient). Version 3.7.1 came out on 29 October 2026 and needs Python 3.10 or later. - coreyjs/nhl-api-py on GitHub: the source and docs of that wrapper, under the Apache-2.0 license.
Frequently Asked Questions
Zmalski/NHL-API-Reference on GitHub.api-web.nhle.com or api.nhle.com, and every valid URL returned 200. Make sure that your client follows redirects, because now and current URLs answer with HTTP 307 first.https://statsapi.web.nhl.com/api/v1/teams. Use the new hosts instead: https://api.nhle.com/stats/rest/en/team lists the teams, and https://api-web.nhle.com/v1/roster/TOR/20262027 gives a roster.hockey/nhl next to the NBA, NFL and MLB.api-web.nhle.com are cached for up to 19 seconds, so poll a live game no faster than that, and cache finished games.https://api-web.nhle.com/v1/gamecenter/{gameId}/play-by-play. For Toronto at Vancouver on 31 October 2026, /v1/gamecenter/2026020224/play-by-play returned 308 plays. Sort them on sortOrder. Field table above.Need Licensed NHL Data?
The NHL API suits personal projects. A commercial app needs licensed data, an SLA and support. Compare providers in our directory.