A JSON API that returns the scores and goals from the latest finished or on-going NHL games. 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
This API is available at https://nhl-score-api.herokuapp.com/, and it serves as the backend for nhl-recap.
The NHL Web API responses are cached in-memory for one minute, and then refreshed upon the next request. So there can be quite a bit of variance in response times.
Returns an object with the date and the scores from the latest round’s games.
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)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.
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)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"
}
},
{
"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"
}
}
]
}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": {}
}
]
}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 teams’ 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 teams’ season statistics before the game, with the fields:recordsobject: each teams’ 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 teams’ 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 teams’ 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 teams’ 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 teams’ 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 teams’ 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 teams’ 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)
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 8
- Leiningen is used for all project management.
- Docker can be used optionally for running the application locally.
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.
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.
The API is deployed to Heroku by running the Deployment workflow. The 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
Usual deployment process:
# Bump version
lein release <:minor|:patch>
# Build
lein uberjar
# Deploy to Heroku
./deploy.sh
# Push to Git
git push origin master --tags- 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
- Ensure the Java agent is set too:
heroku config:set JAVA_OPTS='-javaagent:newrelic/newrelic.jar' - No need to copy New Relic JAR files locally, they are downloaded in
deploy.sh
- Install and set up the Heroku CLI
- Install the Heroku Java CLI plugin:
heroku plugins:install java
# alternative if the above doesn't work:
heroku plugins:install @heroku-cli/plugin-java- 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.