A JSON API that returns the scores and goals from the latest finished or on-going NHL games. The API is available at https://nhl-score-api.herokuapp.com/, and it serves as the backend for nhl-recap.
The data source responses are cached in-memory for one minute, and then refreshed upon the next request. This means in practice:
- the data for a particular request usually refreshes once a minute at best
- there can be quite a bit of variance in response times
The data is sourced from the same NHL Web API at https://api-web.nhle.com that the NHL website uses. The NHL Web API is undocumented but unofficial documentation exists:
- https://github.com/Zmalski/NHL-API-Reference: fairly recent, seems very comprehensive and updated lately
- https://gitlab.com/dword4/nhlapi: older, plenty of discussion in its issues (thoughly mainly on the previous NHL Web API version)
How we use the NHL Web API:
- schedule gives us a list of the week’s games; we check the game statuses and get the game IDs to fetch the games’ gamecenter landing page and right-rail data
- landing gives us basic details of an individual game
- right-rail gives us more details of an individual game, like game stats and recap video links
- standings gives us team stats
Data fetching sequence diagram
sequenceDiagram
participant S as NHL Score API
participant W as NHL Web API
participant H as NHL website
S->>W: GET /v1/schedule/:date
W-->>S: { "gameWeek": ... }
loop Each needed game
S->>W: GET /v1/gamecenter/:game-id/landing
W-->>S: { "summary": ... }
S->>W: GET /v1/gamecenter/:game-id/right-rail
W-->>S: { "teamGameStats": ... }
opt When include=rosters
S->>H: GET roster report page (URL from right-rail)
H-->>S: HTML
end
end
loop Each needed date
S->>W: GET /v1/standings/:date
W-->>S: { "standings": ... }
end
Returns an object with the date and the scores from the latest round’s games.
Optional query parameter: Use include=rosters to include roster data (dressed and scratched players) for each started game when available.
The date object contains the date in a raw format and a prettier, displayable format, or
null if there are no scores.
The games array contains details of the games, each game item containing these fields:
status(object)startTime(string)goals(array)scores(object)teams(object)gameStats(object)preGameStats(object)currentStats(object)links(object)meta(object)rosters(object) (only present when requested withinclude=rostersand roster data is available for the game)errors(array) (only present if data validity errors were detected)
The fields are described in more detail in Response fields.
Returns an array of objects with the date and the scores from given date range’s games.
Both startDate and endDate are inclusive, and endDate is optional. The range is
limited to a maximum of 7 days to set some reasonable limit for the (cached) response;
this also matches the NHL Web API that returns one week’s schedule at a time.
Optional query parameter: Use include=rosters to include roster data (dressed and scratched players) for each started game when available.
The date object contains the date in a raw format and a prettier, displayable format. Contrary to the
/api/scores/latest endpoint, the date is included even if that date has no scheduled games.
Though see the "If a date has no scheduled games" part below for possible peculiarities in that case.
The games array contains details of the games, each game item containing these fields:
status(object)startTime(string)goals(array)scores(object)teams(object)gameStats(object)preGameStats(object)currentStats(object)links(object)meta(object)rosters(object) (only present when requested withinclude=rostersand roster data is available for the game)errors(array) (only present if data validity errors were detected)
If a date has no scheduled games, you will either get:
- no entry for that date in the response, or
- an entry with an empty
gamesarray
This variety comes directly from the NHL Web API response, I don’t know why it
behaves differently for some date ranges than others. Check the entries’ date > raw
field to see what dates are actually included.
The fields are described in more detail in Response fields.
Example of a single regular season date in the API response
{
"date": {
"raw": "2017-10-16",
"pretty": "Mon Oct 16"
},
"games": [
{
"status": {
"state": "FINAL"
},
"startTime": "2016-02-29T00:00:00Z",
"goals": [
...
{
"period": "OT",
"scorer": {
"player": "David Krejci",
"playerId": 8471276,
"seasonTotal": 1
},
"assists": [
{
"player": "Torey Krug",
"playerId": 8476792,
"seasonTotal": 3
},
{
"player": "Zdeno Chara",
"playerId": 8465009,
"seasonTotal": 2
}
],
"team": "BOS",
"min": 2,
"sec": 36,
"strength": "PPG"
}
],
"scores": {
"BOS": 4,
"CHI": 3,
"overtime": true
},
"teams": {
"away": {
"abbreviation": "BOS",
"id": 6,
"locationName": "Boston",
"shortName": "Boston",
"teamName": "Bruins"
},
"home": {
"abbreviation": "CHI",
"id": 16,
"locationName": "Chicago",
"shortName": "Chicago",
"teamName": "Blackhawks"
}
},
"gameStats": {
"blocked": {
"BOS": 8,
"CHI": 9
},
"faceOffWinPercentage": {
"BOS": "45.5",
"CHI": "54.5"
},
"giveaways": {
"BOS": 5,
"CHI": 12
},
"hits": {
"BOS": 22,
"CHI": 22
},
"pim": {
"BOS": 6,
"CHI": 4
},
"powerPlay": {
"BOS": {
"goals": 0,
"opportunities": 2,
"percentage": "0.0"
},
"CHI": {
"goals": 1,
"opportunities": 3,
"percentage": "33.3"
}
},
"shots": {
"BOS": 37,
"CHI": 25
},
"takeaways": {
"BOS": 8,
"CHI": 9
}
},
"preGameStats": {
"records": {
"BOS": {
"wins": 43,
"losses": 31,
"ot": 7
},
"CHI": {
"wins": 50,
"losses": 22,
"ot": 9
}
},
"streaks": {
"BOS": {
"count": 1,
"type": "WINS"
},
"CHI": {
"count": 2,
"type": "LOSSES"
}
},
"standings": {
"BOS": {
"conferenceRank": "5",
"divisionRank": "3",
"leagueRank": "9",
"pointsFromPlayoffSpot": "+15"
},
"CHI": {
"conferenceRank": "11",
"divisionRank": "6",
"leagueRank": "25",
"pointsFromPlayoffSpot": "-3"
}
}
},
"currentStats": {
"records": {
"BOS": {
"wins": 44,
"losses": 31,
"ot": 7
},
"CHI": {
"wins": 50,
"losses": 22,
"ot": 10
}
},
"streaks": {
"BOS": {
"count": 2,
"type": "WINS"
},
"CHI": {
"count": 1,
"type": "OT"
}
},
"standings": {
"BOS": {
"conferenceRank": "5",
"divisionRank": "2",
"leagueRank": "8",
"pointsFromPlayoffSpot": "+17"
},
"CHI": {
"conferenceRank": "11",
"divisionRank": "6",
"leagueRank": "25",
"pointsFromPlayoffSpot": "-4"
}
}
},
"links": {
"gameCenter": "https://www.nhl.com/gamecenter/bos-vs-chi/2023/10/24/2023020092",
"videoRecap": "https://www.nhl.com/video/recap-bruins-at-blackhawks-10-24-23-6339814966112"
},
"meta": {
"gameId": 2023020092,
"gameType": "REGULAR_SEASON",
"seasonId": 20232024
}
},
{
"status": {
"state": "LIVE",
"progress": {
"currentPeriod": 3,
"currentPeriodOrdinal": "3rd",
"currentPeriodTimeRemaining": {
"pretty": "01:58",
"min": 1,
"sec": 58
}
}
},
"startTime": "2016-02-29T02:30:00Z",
"goals": [
...
{
"period": "OT",
"scorer": {
"player": "Kyle Turris",
"playerId": 8474068,
"seasonTotal": 1
},
"assists": [
{
"player": "Mika Zibanejad",
"playerId": 8476459,
"seasonTotal": 3
}
],
"team": "OTT",
"min": 17,
"sec": 30,
"emptyNet": true
}
],
"scores": {
"OTT": 3,
"DET": 1
},
"teams": {
"away": {
"abbreviation": "OTT",
"id": 9,
"locationName": "Ottawa",
"shortName": "Ottawa",
"teamName": "Senators"
},
"home": {
"abbreviation": "DET",
"id": 17,
"locationName": "Detroit",
"shortName": "Detroit",
"teamName": "Red Wings"
}
},
"gameStats": {
"blocked": {
"OTT": 6,
"DET": 3
},
"faceOffWinPercentage": {
"OTT": "42.3",
"DET": "57.7"
},
"giveaways": {
"OTT": 4,
"DET": 7
},
"hits": {
"OTT": 11,
"DET": 15
},
"pim": {
"OTT": 2,
"DET": 4
},
"powerPlay": {
"OTT": {
"goals": 1,
"opportunities": 2,
"percentage": "50.0"
},
"DET": {
"goals": 0,
"opportunities": 1,
"percentage": "0.0"
}
},
"shots": {
"OTT": 19,
"DET": 24
},
"takeaways": {
"OTT": 4,
"DET": 7
}
},
"preGameStats": {
"records": {
"OTT": {
"wins": 43,
"losses": 28,
"ot": 10
},
"DET": {
"wins": 33,
"losses": 36,
"ot": 12
}
},
"streaks": {
"OTT": {
"count": 3,
"type": "LOSSES"
},
"DET": {
"count": 1,
"type": "WINS"
}
},
"standings": {
"OTT": {
"conferenceRank": "15",
"divisionRank": "8",
"leagueRank": "29",
"pointsFromPlayoffSpot": "0"
},
"DET": {
"conferenceRank": "12",
"divisionRank": "7",
"leagueRank": "23",
"pointsFromPlayoffSpot": "+2"
}
}
},
"currentStats": {
"records": {
"OTT": {
"wins": 43,
"losses": 28,
"ot": 10
},
"DET": {
"wins": 33,
"losses": 36,
"ot": 12
}
},
"streaks": {
"OTT": {
"count": 1,
"type": "WINS"
},
"DET": {
"count": 1,
"type": "LOSSES"
}
},
"standings": {
"OTT": {
"conferenceRank": "15",
"divisionRank": "8",
"leagueRank": "29",
"pointsFromPlayoffSpot": "+2"
},
"DET": {
"conferenceRank": "12",
"divisionRank": "7",
"leagueRank": "23",
"pointsFromPlayoffSpot": "0"
}
}
},
"links": {
"gameCenter": "https://www.nhl.com/gamecenter/ott-vs-det/2023/12/09/2023020412"
},
"meta": {
"gameId": 2023020412,
"gameType": "REGULAR_SEASON",
"seasonId": 20232024
}
}
]
}Example of a single playoff date in the API response
{
"date": {
"raw": "2017-10-16",
"pretty": "Mon Oct 16"
},
"games": [
{
"status": {
"state": "PREVIEW"
},
"startTime": "2016-02-29T02:30:00Z",
"goals": [],
"scores": {
"NYR": 0,
"PIT": 0
},
"teams": {
"away": {
"abbreviation": "NYR",
"id": 3,
"locationName": "New York",
"shortName": "NY Rangers",
"teamName": "Rangers"
},
"home": {
"abbreviation": "PIT",
"id": 5,
"locationName": "Pittsburgh",
"shortName": "Pittsburgh",
"teamName": "Penguins"
}
},
"preGameStats": {
"records": {
"NYR": {
"wins": 48,
"losses": 28,
"ot": 6
},
"PIT": {
"wins": 50,
"losses": 21,
"ot": 11
}
},
"playoffSeries": {
"round": 0,
"wins": {
"NYR": 1,
"PIT": 1
}
}
},
"currentStats": {
"records": {
"NYR": {
"wins": 48,
"losses": 28,
"ot": 6
},
"PIT": {
"wins": 50,
"losses": 21,
"ot": 11
}
},
"playoffSeries": {
"round": 0,
"wins": {
"NYR": 1,
"PIT": 1
}
}
},
"links": {},
"meta": {
"gameId": 2022030132,
"gameType": "PLAYOFF",
"seasonId": 20222023
}
}
]
}raw(string): the raw date in "YYYY-MM-DD" format, usable for any kind of processingpretty(string): a prettified format, can be shown as-is in the client
statusobject: current game status, with the fields:state(string):"CANCELED"if the game has been canceled"FINAL"if the game has ended"LIVE"if the game is still in progress"POSTPONED"if the game has been postponed"PREVIEW"if the game has not started yet
progressobject: game progress, only present ifstateis"LIVE", with the fields:currentPeriod(number): current period as a numbercurrentPeriodOrdinal(string): current period as a display string (e.g."2nd")currentPeriodTimeRemaining(object): time remaining in current period:pretty(string): time remaining in prettifiedmm:ssformat;"END"if the current period has endedmin(number): minutes remaining;0if the current period has endedsec(number): seconds remaining;0if the current period has ended
startTimestring: the game start time in standard ISO 8601 format "YYYY-MM-DDThh:mm:ssZ"goalsarray: list of goal details, in the order the goals were scored- gameplay goal:
assists(array) of objects with the fields (an empty array for unassisted goals):player(string): the name of the player credited with the assistplayerId(number): player ID in NHL APIs (can be used to fetch other resources from NHL APIs)seasonTotal(number): the number of assists the player has had this season
emptyNet(boolean): set totrueif the goal was scored in an empty net, absent if it wasn’tmin(number): the goal scoring time minutes, from the start of the periodperiod(string): in which period the goal was scored;"OT"means regular season 5 minute overtimescorer(object):player(string): the name of the goal scorerplayerId(number): player ID in NHL APIs (can be used to fetch other resources from NHL APIs)seasonTotal(number): the number of goals the player has scored this season
sec(number): the goal scoring time seconds, from the start of the periodstrength(string): can be set to"PPG"(power play goal) or"SHG"(short handed goal); absent if the goal was scored on even strengthteam(string): the team that scored the goal
- shootout goal:
period(string):"SO"scorer(object):player(string): the name of the goal scorerplayerId(number): player ID in NHL APIs (can be used to fetch other resources from NHL APIs)
team(string): the team that scored the goal
- gameplay goal:
scoresobject: each team’s goal count, plus one of these possible fields:overtime: set totrueif the game ended in overtime, absent if it didn’tshootout: set totrueif the game ended in shootout, absent if it didn’t
teamsobject:away(object): away team info:abbreviation: team name abbreviationid: team ID in NHL APIs (can be used to fetch other resources from NHL APIs)locationName: team location name, e.g."New York"shortName: team short name, e.g."NY Rangers"teamName: team name, e.g."Rangers"
home(object): home team info:abbreviation: team name abbreviationid: team ID in NHL APIs (can be used to fetch other resources from NHL APIs)locationName: team location name, e.g."St. Louis"shortName: team short name, e.g."St Louis"(note: "St" without a period)teamName: team name, e.g."Blues"
gameStatsobject: each team’s game statistics, with the fields (only included in started games):blocked: blocked shotsfaceOffWinPercentage: what it saysgiveaways: what it sayshits: what it sayspim: penalties in minutespowerPlay(object):goals: number of power play goalsopportunities: number of power play opportunitiespercentage: power play efficiency, e.g.50.0
shots: shots on goaltakeaways: what it says
preGameStatsobject: each team’s season statistics before the game, with the fields:recordsobject: each team’s record for this regular season, with the fields:wins(number): win count (earning 2 pts)losses(number): regulation loss count (0 pts)ot(number): loss count for games that went to overtime (1 pt)
playoffSeriesobject: current playoff series related information (only present in playoff games), with the fields:round(number): the game’s playoff round;0for the Stanley Cup Qualifiers best-of-5 series (in 2020 due to COVID-19), actual playoffs start from1wins(object): each team’s win count in the series
streaksobject: each team’s current form streak (only present in regular season games), with the fields (ornullif the team hasn’t played during the season yet):type(string):"WINS"(wins in regulation, OT or SO),"LOSSES"(losses in regulation) or"OT"(losses in OT or SO)count(number): streak’s length in consecutive games
standingsobject: each team’s standings related information, with the fields:divisionRank(string): the team’s regular season ranking in their division (based on point percentage); this comes as a string value from the NHL Web API (can be an empty string before the season has started)conferenceRank(string): the team’s regular season ranking in their conference (based on point percentage, not considering wildcard seedings); this comes as a string value from the NHL Web API (can be an empty string before the season has started)leagueRank(string): the team’s regular season ranking in the league (based on point percentage); this comes as a string value from the NHL Web API (can be an empty string before the season has started)pointsFromPlayoffSpot(string): point difference to the last playoff spot in the conference (can be an empty string before the season has started)- for teams currently in the playoffs, this is the point difference to the first team out of the playoffs; i.e. by how many points the team is safe
- for teams currently outside the playoffs, this is the point difference to the team in the last playoff spot (2nd wildcard position); i.e. by how many points (at minimum) the team needs to catch up
- Note: this value only indicates point differences and doesn’t consider which team is ranked higher if they have the same number of points
currentStatsobject: each team’s current (ie. after the game if it has finished and NHL have updated their stats) season statistics on the game date, with the fields:recordsobject: each team’s record for this regular season, with the fields:wins(number): win count (earning 2 pts)losses(number): regulation loss count (0 pts)ot(number): loss count for games that went to overtime (1 pt)
streaksobject (ornullif querying coming season’s games): each team’s current form streak (only present in regular season games), with the fields:type(string):"WINS"(wins in regulation, OT or SO),"LOSSES"(losses in regulation) or"OT"(losses in OT or SO)count(number): streak’s length in consecutive games
standingsobject (ornullif querying coming season’s games): each team’s standings related information, with the fields:divisionRank(string): the team’s regular season ranking in their division (based on point percentage); this comes as a string value from the NHL Web APIconferenceRank(string): the team’s regular season ranking in their conference (based on point percentage, not considering wildcard seedings); this comes as a string value from the NHL Web APIleagueRank(string): the team’s regular season ranking in the league (based on point percentage); this comes as a string value from the NHL Web APIpointsFromPlayoffSpot(string): point difference to the last playoff spot in the conference- for teams currently in the playoffs, this is the point difference to the first team out of the playoffs; i.e. by how many points the team is safe
- for teams currently outside the playoffs, this is the point difference to the team in the last playoff spot (2nd wildcard position); i.e. by how many points (at minimum) the team needs to catch up
- Note: this value only indicates point differences and doesn’t consider which team is ranked higher if they have the same number of points
playoffSeriesobject: current playoff series related information (only present in playoff games), with the fields:round(number): the game’s playoff round;0for the Stanley Cup Qualifiers best-of-5 series (in 2020 due to COVID-19), actual playoffs start from1wins(object): each team’s win count in the series
linksobject: links to related pages on the official NHL site, with the optional fields:gameCenter: game summary with lots of related infoplayoffSeries: playoff series specific info (only present in playoff games)videoRecap: 5-minute video recap (once available)
metaobject: metadata about the game, with the fields:gameId(number): game ID in NHL APIs (can be used to fetch other resources from NHL APIs)gameType(string):"REGULAR_SEASON"or"PLAYOFF"seasonId(number): the season ID in "YYYYYYYY" format (e.g.20232024)
rostersobject: each team’s dressed and scratched players (only present when roster data is available for finished or live games), with the fields:dressedPlayers(array): players in the lineup, each item containing:name(string)number(number)position(string):"C"for center"D"for defense"G"for goalie"L"for left wing"R"for right wing
startingLineup(boolean): present andtrueonly for players in the starting lineup (for example, the starting goalie)
scratchedPlayers(array): scratched players, each item containing:name(string)number(number)position(string):"C"for center"D"for defense"G"for goalie"L"for left wing"R"for right wing
errorsarray: list of data validation errors, only present if any were detected. Sometimes the NHL Web API temporarily contains invalid or missing data. Currently we check if the goal data from the NHL Web API (read from itsscoringPlaysfield) contains the same number of goals than the score data (read from itsteamsfield). If it doesn’t, two different errors can be reported:{ "error": "MISSING-ALL-GOALS" }: all goal data is missing; this has happened occasionally{ "error": "SCORE-AND-GOAL-COUNT-MISMATCH", "details": { "goalCount": 3, "scoreCount": 4 } }: goal data exists but doesn’t contain the same number of goals than the teams’ scores; haven’t noticed this happen but good to check anyway
Note on overtimes: Only regular season 5 minute overtimes are considered "overtime" in the
goals array. Playoff overtime periods are returned as period 4, 5, and so on, since they are
20 minute periods. However, all games (including playoff games) that went into overtime are
marked as having ended in overtime in the scores object.
- Java version 17
- Leiningen is used for project management.
- Docker can be used optionally for running the application locally.
This project uses SDKMAN! to manage Java and Leiningen versions. A .sdkmanrc
file in the project root specifies the required versions.
To set up the correct versions:
- Install SDKMAN! if you haven't already
- Navigate to the project directory
- Run
sdk envactivate the correct versions (you will be prompted to install them if needed)
Using Docker
To run the application locally in Docker containers, install Docker and run:
./docker-up.shDownloading the Clojure image will take quite a while on the first run, but it will be reused after that.
To delete all containers, run:
./docker-down.shYou can also run the application locally with lein run.
The application can be configured using the following environment variables:
MAX_CONCURRENT_API_REQUESTS(default: "3"): The maximum number of concurrent API requests to the NHL Web APIPORT(default: "8081"): The port on which the HTTP server listens
Run tests with the Kaocha test runner for improved test failure reporting:
lein test [--watch]Run single tests or test groups with Kaocha’s --focus argument, e.g.:
lein test --focus nhl-score-api.fetchers.nhlstats.game-scores-test/game-scores-parsing-scoresFormatting is done with cljfmt.
Format the code automatically:
lein formatOnly check the formatting without making changes:
lein format-checkLint the code with the clj-kondo Leiningen plugin:
lein lintThe NHL API responses change from time to time, so the responses used in tests also need to be updated to remain accurate.
Especially the game-specific API responses need frequent updating, so there is a helper script to fetch the current responses with game IDs and save them. It’s also useful for checking if the NHL API responses have changed in case of errors. Though note that not all data should be updated; at least game progress data changes should be discarded so that the tests that rely on that still work.
The script is called update-game-test-data.sh and it uses curl for fetching and jq
for formatting, so you’ll need those installed.
Example:
$ ./scripts/update-game-test-data.sh 2023020205 2023020206
Fetching landing for game ID 2023020205
Landing response saved to test/nhl_score_api/fetchers/nhl_api_web/resources/landing-2023020205.json
Fetching right-rail for game ID 2023020205
Right-rail response saved to test/nhl_score_api/fetchers/nhl_api_web/resources/right-rail-2023020205.json
Fetching landing for game ID 2023020206
Landing response saved to test/nhl_score_api/fetchers/nhl_api_web/resources/landing-2023020206.json
Fetching right-rail for game ID 2023020206
Right-rail response saved to test/nhl_score_api/fetchers/nhl_api_web/resources/right-rail-2023020206.jsonThere is also a similar script update-standings-test-data.sh for updating standings test data.
Usual deployment process:
- Bump the version
lein release <:minor|:patch> git push origin master --tags
- Deploy to Heroku by running the Deployment workflow.
- Alternatively deploy from a development machine:
lein uberjar ./deploy.sh
- Alternatively deploy from a development machine:
The deployment workflow requires these set in Actions secrets and variables in the repository’s settings:
HEROKU_API_KEYrepository secret: you can find this from the API Key section in your Heroku account settingsHEROKU_APP_NAMErepository variable: your app name in Heroku
- Create a Java web app in Heroku
- Add and set up the New Relic APM Heroku add-on
- The add-on will automatically add the necessary Heroku environment variables
- No need to copy New Relic JAR files locally, they are downloaded in
deploy.sh
- Install and set up the Heroku CLI
- If you have multiple Heroku apps, set the default app for this repository:
heroku git:remote -a <heroku-app-name>This project has been a grateful recipient of the Futurice Open Source sponsorship program.