NHL Edge API — Investigation Findings¶
Research notes on the endpoints powering nhl.com/nhl-edge (puck- and player-tracking data: shot speed, skating speed/distance, zone time, shot/save locations).
Unverified/unofficial: reverse-engineered from the NHL Edge web app (webpack bundles + live network capture + direct probing, ~1000 requests). All requests are plain
GETJSON onhttps://api-web.nhle.com— no auth, no cookies; a browser-likeUser-Agentis sufficient.
How this was investigated¶
- Scraped the SPA's JS bundles (
wsr.nhle.com/static/js/) and extracted the webpack chunk-name map (EdgeTeamLanding,EdgeSkaterDetail,EdgeComparison, ...). - Loaded the site in headless Chromium (Playwright) and captured live API traffic while clicking through every page/tab.
- Systematically probed candidate routes (
{skater|goalie|team}×{view}×{detail|details|top-10}× parameter combinations) across two seasons.
Endpoint catalog¶
Conventions:
- {season} = 8-digit form (20252026). now is accepted in place of {season}/{gameType} and
307-redirects to the resolved current season (follow redirects).
- {gameType} = 2 (regular season), 3 (playoffs).
- {situation} = all | es (even strength) | pp | pk — many top-10s only return data for all.
- Valid route + no data ⇒ [] (HTTP 200, 2-byte empty array), not 404.
- Measures come in imperial/metric pairs: {"imperial": 102.4, "metric": 165.0, "overlay": {...}}.
1. Landing pages (leaderboards)¶
| Endpoint | Leader keys in response |
|---|---|
GET /v1/edge/skater-landing/{season}/{gameType} |
hardestShot, maxSkatingSpeed, totalDistanceSkated, distanceMaxGame, highDangerSOG, offensiveZoneTime, defensiveZoneTime |
GET /v1/edge/goalie-landing/{season}/{gameType} |
highDangerSavePctg, highDangerSaves, highDangerGoalsAgainst, savePctg5v5, gamesAbove900 (plus minimumGamesPlayed) |
GET /v1/edge/team-landing/{season}/{gameType} |
shotAttemptsOver90, burstsOver22, distancePer60, highDangerSOG, offensive/neutral/defensiveZoneTime |
All three also accept /now and return seasonsWithEdgeStats: [{id, gameTypes}] for season discovery.
2. Base detail¶
| Endpoint | Response shape |
|---|---|
GET /v1/edge/skater-detail/{playerId}/{season}/{gameType} |
player bio + percentile stats vs league avg |
GET /v1/edge/goalie-detail/{goalieId}/{season}/{gameType} |
player + stats.{metric: {value, percentile, leagueAvg}} (GAA, gamesAbove900, goalDifferentialPer60, goalSupportAvg, ...) |
GET /v1/edge/team-detail/{teamId}/{season}/{gameType} |
team equivalent |
3. Comparison (fetch each side separately, diff client-side)¶
| Endpoint |
|---|
GET /v1/edge/skater-comparison/{playerId}/{season}/{gameType} |
GET /v1/edge/goalie-comparison/{goalieId}/{season}/{gameType} |
GET /v1/edge/team-comparison/{teamId}/{season}/{gameType} |
Skater comparison bundles shotSpeedDetails, skatingSpeedDetails, skating distance, zone time,
shot locations — one call per entity; the UI requests both entities it compares. /now also works.
4. View-specific detail¶
| Endpoint | Notes |
|---|---|
GET /v1/edge/skater-shot-speed-detail/{playerId}/{season}/{gt} |
topShotSpeed/avgShotSpeed + attempts buckets (100+/90-100/80-90/70-80) |
GET /v1/edge/skater-skating-speed-detail/{playerId}/{season}/{gt} |
maxSkatingSpeed + bursts over 22/20/18 mph |
GET /v1/edge/skater-skating-distance-detail/{playerId}/{season}/{gt} |
distance per game/situation |
GET /v1/edge/skater-shot-location-detail/{playerId}/{season}/{gt} |
shotLocationDetails[] (by area: sog, goals, pctg, percentile) + shotLocationTotals[] (with league avgs) |
GET /v1/edge/goalie-shot-location-detail/{goalieId}/{season}/{gt} |
save % by danger zone (all/highDanger/midRange/longRange) |
GET /v1/edge/team-shot-speed-detail/{teamId}/{season}/{gt} |
|
GET /v1/edge/team-skating-speed-detail/{teamId}/{season}/{gt} |
|
GET /v1/edge/team-skating-distance-detail/{teamId}/{season}/{gt} |
|
GET /v1/edge/team-shot-location-detail/{teamId}/{season}/{gt} |
|
GET /v1/edge/team-zone-time-details/{teamId}/{season}/{gt} |
⚠️ plural -details (only endpoint like this): zone % + rank + league avg by strength, plus shotDifferential |
5. Top-10 leaderboards¶
| Endpoint | Params |
|---|---|
GET /v1/edge/skater-shot-speed-top-10/{situation}/{sort}/{season}/{gt} |
sort max (others return []) |
GET /v1/edge/team-shot-speed-top-10/{situation}/{sort}/{season}/{gt} |
same |
GET /v1/edge/team-skating-speed-top-10/{situation}/{sort}/{season}/{gt} |
same |
GET /v1/edge/skater-shot-location-top-10/{situation}/{metric}/{filter}/{season}/{gt} |
metric sog|goals, filter all |
GET /v1/edge/team-shot-location-top-10/{situation}/{metric}/{filter}/{season}/{gt} |
same |
GET /v1/edge/team-skating-distance-top-10/{situation}/{param}/{sort}/{season}/{gt} |
only all/all/total returns data |
GET /v1/edge/team-zone-time-top-10/{situation}/{zone}/{season}/{gt} |
zone offensive|defensive|neutral |
GET /v1/edge/goalie-shot-location-top-10/{metric}/{situation}/{season}/{gt} |
⚠️ param order differs: metric first (save-pctg|saves|goals-against), situation all only |
Not found (404) — checked exhaustively¶
skater-skating-speed-top-10,skater-zone-time-top-10,goalie-*-top-10(except shot-location),skater|goalie|team-five-on-five-*,*-save-locations-*,*-save-pctg-detail— these views are served by the base detail/comparison payloads or the landing leaders instead.goalie-shot-location-*at season20242025(data starts20252026for that family); skater/team families have data from20242025.
Data availability & quirks¶
- Seasons: tracking data exists from 2024-25 onward (goalie save locations from 2025-26).
Use
seasonsWithEdgeStatsfrom any landing/detail response to discover valid combos. - Empty ≠ invalid: e.g.
.../es/max/...top-10s legitimately return[]. /nowredirects (307) — make sure the HTTP client follows redirects.- Team IDs are the numeric NHL team id (e.g.
14= Tampa Bay); player IDs are the standard NHL player id (e.g.8478402= Connor McDavid). - i18n: names come localized (
firstName: {default, cs, fi, sk}).
The Goal Visualizer (Puck & Player Tracking replays)¶
The site's player/puck location feature is the "EDGE | Goal Visualizer" at
https://www.nhl.com/ppt-replay/goal/{gameId}/{eventId} — an animated 2D rink view of every goal,
built from actual Puck & Player Tracking frames. It is not a live stream: no WebSockets exist on
the site (verified by capture); it is per-goal-event replay data, published shortly after each goal.
Data pipeline¶
- Get goal events from
GET /v1/wsc/play-by-play/{gameId}(already in the library). Goals havetypeDescKey: "goal"; noteeventIdthere (e.g.95). GET https://api-web.nhle.com/v1/ppt-replay/{gameId}/{eventId}→ goal metadata incl.goal.pptReplayUrl(e.g.https://wsr.nhle.com/sprites/20252026/2025020740/ev95.json). The/ppt-replay/goal/...variant just 307-redirects to this. Preseason games return nopptReplayUrl(no tracking coverage); regular-season goals have it.GET {pptReplayUrl}→ the tracking frames. RequiresReferer: https://www.nhl.com/header (UA alone orOriginalone → 403 from the CDN — same guard as the wsr.nhle.com JS chunks).
Sprite frame format¶
[
{
"timeStamp": 17685233789, // frame counter, 1 unit = 0.1s (10 fps)
"onIce": {
"1": { "id": 1, "playerId": "", "x": 2352.46, "y": 390.10,
"sweaterNumber": "", "teamId": "", "teamAbbrev": "" }, // ← the PUCK is id "1"
"7006": { "id": 7006, "playerId": 8484145, "x": 2364.78, "y": 713.54,
"sweaterNumber": 6, "teamId": 7, "teamAbbrev": "BUF" }
}
}
]
- ~140 frames per goal ≈ 14 s of play leading to the score (10 fps).
- 13–15 entities per frame: 12 players (5v5 + 2 goalies, occasional extra during changes) + puck.
- Player keys look like
{teamId digit}{sweaterNumber}(e.g.7006= BUF #6). - Coordinates are inches: x ∈ [0, 2400] (200 ft rink length), y ∈ [0, 1020] (85 ft width). Divide by 12 for feet. Puck step ≈ 23–41 in/frame ≈ 13–23 mph — sanity-checks the 10 fps reading.
Proposed client integration (not yet implemented)¶
A natural fit is a new EdgeClient on the existing HttpClient (Web API host):
client.edge.get_skater_landing(season="20252026", game_type=2)
client.edge.get_skater_detail(player_id=8482095, season="now")
client.edge.get_team_zone_time_details(team_id=14) # plural route!
client.edge.get_goalie_shot_location_top_10(metric="save-pctg")
client.edge.compare_skaters(8482095, 8478402, season="20252026")
# Goal Visualizer tracking frames (needs the Referer header on the sprites host)
client.edge.get_goal_frames(game_id=2025020740, event_id=95)
# → follows ppt-replay metadata → pptReplayUrl → frame list (puck id "1", inches, 10 fps)
All ~28 routes above would register in edgework/endpoints.py under an edge_* namespace and join
the endpoint-coverage manifest. The Goal Visualizer needs one HttpClient addition: the sprites
host requires Referer: https://www.nhl.com/ (the existing get_ppt_replay methods in
GameClient already return the metadata; only the pptReplayUrl follow-through is new).