# Ice Hockey Scraper — NHL, KHL Box Scores & Odds (`scapypuppy/ice-hockey-scraper-nhl`) Actor

NHL, KHL, SHL and Liiga: score by period, per-skater box score and, where the league publishes them, shots on goal, saves, power play, faceoffs and hits per period. Shoot-outs, fixtures, arena and head-to-head history. A week of results back and a week of fixtures ahead. No login, no browser.

- **URL**: https://apify.com/scapypuppy/ice-hockey-scraper-nhl.md
- **Developed by:** [Huyen Trang](https://apify.com/scapypuppy) (community)
- **Categories:** Sports
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.35 / 1,000 match with score and competitions

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Ice Hockey Scraper — NHL, KHL Box Scores & Odds

NHL, KHL, SHL and Liiga games with **up to 18 team statistics per period** wherever the league publishes a stat sheet — the NHL, KHL and AHL do — shots on goal, goalkeeper saves, power play, faceoffs and hits, plus the score broken out by period, a per-skater box score, fixtures, arena and head-to-head history between the two clubs.

**No login. No cookies. No API key. No headless browser.**

### Quick start

1. Open **Input**: the form is ready to run as it is — the 20 matches closest to today in the 15-day window, top competitions first, with statistics and summary. To narrow it, set **Days** to *Yesterday* and **Competitions** to *NHL*.
2. Press **Start** and wait for the run to finish.
3. Open **Output**, press **Export**, set **View** to **Matches** and the format to **Excel**, then **Download**: one row per match with the match ID, date and time, teams, the score as numbers and as text, status, winner and link. In CSV and Excel **All fields** keeps the score and teams too, but scattered among about 2,000 columns in alphabetical order, and most nested data (statistics, events, box scores) is cut off at that limit; keep it for JSON (see **Getting a spreadsheet**).

With the form as it ships, **Matches**, **Statistics** and **Events** have data; the other tabs of Output stay empty until you turn on the option named in their title.

**Cost:** the form as it ships comes to at most **$0.01105** a run — $0.00005 for the start plus $0.00055 a match; a match without a stat sheet or summary costs less. 1 000 matches cost **$0.55** at the default settings, and Apify's free plan, $5 of usage a month, covers about 9 000 of them. Every option is priced under **What a run actually costs**.

### Recipes

Paste one into the **JSON** view of the Input tab or send it through the API. Prices are per match returned, at this Actor's event prices; the run start adds $0.00005.

**Last night's NHL games** — $0.00055 a match with statistics and summary.

```json
{"dateRange": "yesterday", "tours": ["USA: NHL"]}
```

**Only matches in play, with the score in play** — $0.00055 a match: the result plus the live state.

```json
{"matchStatuses": ["live"], "includeLive": true, "includeStats": false, "includeSummary": false}
```

**Days of the season from the league page** — $0.00035 a match, scores only.

```json
{"leagueUrls": ["https://www.flashscore.com/hockey/usa/nhl/"], "dateFrom": "2026-10-10", "dateTo": "2026-10-12", "includeStats": false, "includeSummary": false}
```

**A team's schedule and recent results** — $0.00035 a match, scores only.

```json
{"teams": ["https://www.flashscore.com/team/detroit-red-wings/dnJdmCF0/"], "teamResultsPages": 1, "includeStats": false, "includeSummary": false}
```

**A results feed on a schedule** — $0.00035 for each new or changed match plus only the parts that changed; an unchanged run pays the start and the run minimum. Run it hourly from a schedule with `isExclusive` on.

```json
{"dateRange": "yesterday-today", "tours": ["USA: NHL"], "monitorMode": true, "monitorName": "nhl"}
```

**US odds board for the next three days** — $0.00075 a match with odds and lines; matches without prices are skipped and not billed.

```json
{"dateRange": "next-3-days", "matchStatuses": ["scheduled"], "tours": ["USA: NHL"], "includeStats": false, "includeSummary": false, "includeOdds": true, "includeOddsMarkets": true, "oddsCountries": ["US"], "oddsFormat": "american", "onlyWithOdds": true}
```

### Getting a spreadsheet

**In Console.** Open the run's **Output** tab and press **Export**. Pick a **View**, then the
format, then **Download**:

| View in Console | `view=` in the API | One row per | Needs |
|---|---|---|---|
| **Matches** | `overview` | match | — |
| **Odds** | `odds` | match | Match-winner odds |
| **Live state** | `live` | match | Live state |
| **Statistics** | `statistics` | statistic per part of the match | Match statistics |
| **Events** | `events` | event of the timeline | Match summary |
| **Lineups: home** | `lineupsHome` | player or coach of the home side | Lineups |
| **Lineups: away** | `lineupsAway` | player or coach of the away side | Lineups |
| **Players** | `players` | player line of the box score | Player box score |
| **Bookmakers** | `bookmakers` | bookmaker | Match-winner odds |
| **Every price** | `prices` | price of every bookmaker | Every bookmaker price |
| **Totals** | `totals` | over/under line | Totals and handicaps |
| **Head-to-head** | `h2h` | direct meeting | Head-to-head history |
| **Changes (monitor runs only)** | `changes` | match the monitor emitted | — |

Long views unwind a list of the record into rows, so one match gives as many rows as it has
statistics, events, players or prices; a match without that part gives a single row with the
match columns and empty columns of the part, and a match whose list is empty gives none. The
dialog opens on **All fields**, which exports every nested field of the records. In CSV and Excel
that stops at 2,000 columns: the top-level fields (score, teams, competition, date) stay, but in
alphabetical order among the rest, while most nested data — statistics, events, box scores,
head-to-head rows — is cut off. Choose **Excel** rather than CSV: Excel opens a CSV with its own
rules, so a text score such as 6-4 can turn into a date, and with a semicolon list separator the
file opens as one column.

**Through the API.** The same views come straight as files:

```
https://api.apify.com/v2/datasets/<datasetId>/items?view=overview&format=xlsx&clean=true
```

Use `format=csv` for CSV and any `view=` name from the table. The column headers of such a file
are field paths (`homeTeam`, `odds.margin`), not the labels of the table in Console. To name the
columns yourself, list the fields and give `outputFields` in the same order; `outputFields` works
with `fields`, not with `view`:

```
https://api.apify.com/v2/datasets/<datasetId>/items?fields=matchId,startDate,homeTeam,awayTeam,homeScore,awayScore&outputFields=Match,Date,Home,Away,Home%20score,Away%20score&format=xlsx
```

The dataset ID is in the run's details and in `defaultDatasetId` of the run object. A link
without a token works when your account lets anyone with the ID read your storages; with
**Restricted** access send your token in the `Authorization: Bearer` header or use
**Export → Copy shareable link**. Apify keeps your ten most recent runs with their datasets; older
runs are deleted with their storages after your plan's data retention period
([Apify docs](https://docs.apify.com/actors/running/runs-and-builds)), so keep the files you need
or append the records to a named dataset (`outputDatasetName`).

**A named dataset has no views.** The Actor opens it without its dataset schema, so `view=` does
not work on it and its Export offers only **All fields**. Name the columns with `fields` instead,
for example those of **Matches** and **Changes**:

```
https://api.apify.com/v2/datasets/<dataset ID, or owner~datasetName>/items?fields=matchId,startDate,startTime,tournament,homeTeam,awayTeam,homeScore,awayScore,scoreText,status,statusDetail,winnerName,matchUrl,scrapedAt,changeType,monitorChangedFieldsText,unavailablePartsText&format=xlsx&clean=true
```

### Output example

One record from a local run, 14 October 2026 — Detroit Red Wings vs New Jersey Devils, USA: NHL — trimmed: lists keep their first item and nested objects only some of their fields. Every field is described under **Output fields**.

```json
{
  "matchId": "ADPPI2te",
  "sport": "hockey",
  "startDate": "2026-10-13",
  "startTime": "18:00",
  "startTimeKnown": true,
  "timezone": "America/New_York",
  "homeTeam": "Detroit Red Wings",
  "awayTeam": "New Jersey Devils",
  "homeScore": 4,
  "awayScore": 5,
  "tournament": "USA: NHL",
  "tournamentId": "G2Op923t",
  "startsAt": "2026-10-13T22:00:00.000Z",
  "status": "finished",
  "statusDetail": "after extra time",
  "home": {
    "name": "Detroit Red Wings",
    "id": "dnJdmCF0",
    "url": "https://www.flashscore.com/team/detroit-red-wings/dnJdmCF0/"
  },
  "away": {
    "name": "New Jersey Devils",
    "id": "Kh0O591A",
    "url": "https://www.flashscore.com/team/new-jersey-devils/Kh0O591A/"
  },
  "score": {"home": 4, "away": 5},
  "scoreText": "4-5",
  "creditedScore": {"home": 4, "away": 5},
  "winnerName": "New Jersey Devils",
  "matchUrl": "https://www.flashscore.com/match/ADPPI2te/",
  "stats": {
    "headline": {"Shots on Goal": {"home": "27", "away": "37"}, "Goalkeeper Saves": {"home": "32", "away": "23"}}
  },
  "odds": {
    "market": "HOME_AWAY",
    "priceState": "closing",
    "home": 1.9,
    "away": 1.89,
    "bestHome": 1.93,
    "bestHomeBookmaker": "Unibetuk",
    "bestAway": 1.91,
    "bestAwayBookmaker": "William Hill",
    "bookmakersCount": 20,
    "margin": 5.54,
    "impliedHome": 0.4987,
    "impliedAway": 0.5013,
    "movement": {"homeDirection": "stable"},
    "bookmakers": [
      {
        "bookmaker": "Unibetuk",
        "country": "GB",
        "homePrice": 1.93,
        "awayPrice": 1.9,
        "openingHome": 1.84,
        "openingAway": 1.95,
        "active": false
      }
    ]
  },
  "summary": {
    "venue": "Little Caesars Arena",
    "attendance": 18306,
    "events": [
      {
        "periodName": "1st Period",
        "minute": "06:07",
        "minuteNumber": 6.12,
        "side": "away",
        "type": "Goal",
        "player": "Rodrigues E.",
        "assist": "Noesen S., Mantha A."
      }
    ]
  },
  "playerStats": {
    "players": [
      {
        "name": "Gibson J.",
        "playerId": "h0JhvD5j",
        "number": "36",
        "team": "home",
        "points": 0,
        "penalties_in_minutes": 0,
        "time_on_ice": 61.08,
        "goalkeeper_saves_made": 32
      }
    ]
  },
  "scrapedAt": "2026-10-14T13:55:01.633Z"
}
```

### What you get

| Capability | What it means for you |
|---|---|
| **Team stat sheet per period** | shots, saves, power play and faceoffs for each period, not one game total |
| **Player box score** | goals, assists, points and penalty minutes per skater |
| **Score by period, not just the final result** | period-level data for in-play models, with the clock inside the period live |
| **Missing players** | injured, suspended or rested players with the reason for NHL games |
| **Head-to-head history between clubs** | previous meetings with competition, date and a form summary |
| **Results a week back, fixtures a week ahead** | schedule feed and results feed in one Actor |

### Every field, per match

| Group | Metrics |
|---|---|
| Shooting | Shots on goal, shots off target, blocked shots, shooting percentage |
| Goaltending | Goalkeeper saves, save percentage, empty-net goals |
| Special teams | Power-play goals and percentage, shorthanded goals, penalty-kill percentage, penalties, penalty minutes |
| Faceoffs and puck battles | Faceoffs won and faceoff percentage, hits, giveaways, takeaways |

Every metric comes **per match and per period** — `Match`, `1st Period`, `2nd Period`, `3rd Period` — and the score comes per period as well as full time, so you can see how a game unfolded, not just how it ended.

Every stat row carries a stable snake_case `key` and numbers: `homeValue`/`awayValue`, and for shares like `63% (36/57)` also `homePercent`, `homeMade`, `homeAttempts` (and the away side) — no string parsing before the data goes into a model.

### Match context, not just numbers

Alongside the stat sheet each match carries a summary: **venue, town, attendance,
referees, score broken down by period and match duration** — whatever the source
publishes for that competition. Top-tier games carry the most. Where the source publishes durations, they come both as text and as minutes (`totalDurationMinutes`, `periodDurationsMinutes`).

Every event in the timeline carries the reason in `reason` — Hooking, Tripping, Cross-checking — the source's commentary line, where it publishes one, in `comment`, and the source's short tag in `note`. `period` numbers the part of the match the event belongs to, in order — regulation periods, then overtime and the shoot-out — and `periodName` names it (`1st Period`, `Overtime`, `Penalties`). An event with two assists lists each assistant once in `assist`, with their ids in `assistIds`. A penalty shot scored during play is a `Goal`; the attempts of a shoot-out stay `Penalty` and `Penalty missed`, since they are not goals of the game.

### Form history, not just the last meeting

Head-to-head returns three lists: recent matches of the first side, of the second, and their
direct meetings. Each row carries date, tournament and country, the numeric score, the winner, how the match
ended (regulation, extra time, penalties, walkover or awarded, in `status`), participant IDs and a link to
the match. The default depth is 10 rows per side; set `maxH2HRecords` to 0 for everything the
source publishes — 50 per side. Direct meetings come in full by default (`maxH2HMeetings` = 0), and the source keeps up to about 75 per pair. Clubs that meet several times every season reach that, and then the meetings are the longest of the three lists: 73 and 75 meetings on NHL games, 55 and 69 in the KHL. `maxH2HMeetings` keeps fewer of them. The match itself is flagged
`isThisMatch` and is never billed; matches played after it are left out, so the history of a
past match stops at its own date. The **Head-to-head** view lists the direct meetings, one row
each.

A history row of a match decided by a shoot-out keeps the source's credited score in `score`, one more for the shoot-out winner, while `homeScore` and `awayScore` are the score of play without the shoot-out: 3-3 and a 1-0 shoot-out comes as `score` "4:3" with `homeScore` 3 and `awayScore` 3. `penalties` holds the shoot-out itself and `status` is `penalties`.

Rows are billed one by one, **at most 30 a match**: the rest of the history comes in the same record for free. At the default depth a match holds about 75 to 95 rows, so it is billed 30 — the measured counts are under **What a run actually costs** below.

On top of that comes a **form summary per side**, counted only from matches played before this one: wins, draws and losses, a form string of the last 10 results, the date of the last match and rest days, the home side's record at home and the away side's record away, and which competitions come up most — a form guide for both sides without a second Actor.

### Score fields

- **Ready-made score fields**: `score` (the final score; after a shoot-out it is
  the score of play, with the shoot-out in `penaltyShootout`), `scoreText`
  ("3-2", "2-2 (SO 1-0)"), `winnerName` and a profile `url` for each side.
- **Three scores, three meanings**: `score` (and `homeScore`, `awayScore`) is the score of play including
  overtime, `regulationScore` the score after regular time, and `creditedScore`, in sports
  with shoot-outs, the source's credited result, one more for the winner of the series: 3-3 after play and a 1-0 shoot-out is `score` 3-3, `creditedScore` 4-3.
- **Shoot-outs unpacked**: `regulationScore`, `overtimeScore` and
  `penaltyShootout` as separate fields; `scoreText` writes the shoot-out
  as `(SO 1-0)`.
- `home.countryId` / `away.countryId` are null for clubs: the source gives a numeric
  country id to individual players only.
- Every match carries the tournament's stable id (`tournamentId`) — the key
  to the source's season archives — and, where the competition has seasons,
  `tournamentSeasonId` and `tournamentStageId`. Our League Standings Actor
  returns all three under the same names and values, and its `teamId` equals
  `home.id` and `away.id`, so matches join with their league table.

### Player box score

`includePlayerStats` adds a **line for every player**, 37 to 39 a game in the NHL: up to 16 columns — goals, assists, points, plus-minus, penalty minutes, time on ice, shots on goal, hits, blocked shots, giveaways, takeaways, faceoffs and, for goalkeepers, saves out of shots (`SV` "14-17") and save percentage — with player ids and profile links, on top of the team stat sheet. One extra request per match.

Values come as numbers: a dash in a numeric column is 0, a percentage without a value is null, and playing time stays text ("mm:ss"). A column that holds a pair such as `SV` "14-17" stays text and adds `SV_made` and `SV_attempts`. The shirt number and country come from the lineup by player id: with `includeLineups` off that is one more request per played match, not billed.

With `includeLineups`, NHL games also carry **missing players** — injured, suspended or rested, with the reason.

Each line also carries its main numbers as fields of their own — `goals`, `assists`, `points`, `plus_minus_points`, `penalties_in_minutes`, `time_on_ice` and 11 more, the values of `rawStats` — so the **Players** view and its CSV or Excel export show them as columns.

### News, highlights and where to watch

Three optional feeds, all in the same match record:

- `includeNews` — **news articles linked to the match**: title, outlet, image and
  publication time. Notable matches carry dozens of articles.
- `includeHighlights` — **official video highlight links** with preview images,
  available once the match has been played.
- `includeBroadcasts` — **TV channels and streaming services** the source lists for the
  match, by country, plus bookmaker live streams. Each item has `kind` (`tv` or
  `bookmaker-stream`), the source's `channelId` or `bookmakerId`, and `countryHint`, the
  country as the channel name writes it in brackets (`Usa`, `GBr`), not an ISO code. The
  source's partner links lead to advertising trackers, so their `url` is null. For some
  matches (many football ones) the source sends the channel names only: those items have
  `channelId` and `url` null. When the list has no TV channel at all, one more request
  reads the source's shorter listing, which depends on the country the request comes from.

One request per match per option, two for broadcasts of a match without a channel list.
Each is billed only when data exists; broadcasts only when at least one TV channel is
listed, so bookmaker streams alone are free.

### Season archives: history beyond the daily window

The daily feeds cover seven days back and seven ahead. For anything older, pass
**league and season URLs**: `https://www.flashscore.com/hockey/usa/nhl-2005-2006/` — the 2005-06 NHL
season page listed 805 matches in October 2026.
Old matches return whatever detail the source still holds for them, which thins out
the further back you go.

```json
{ "leagueUrls": ["https://www.flashscore.com/hockey/..."] }
```

**Archive records without a start time.** Some archive pages keep only the day of a match, with a placeholder time at midnight in Europe or at midnight UTC. When at least 90% of the games on a page older than a year sit at such a midnight, those records carry `startTimeKnown: false`, an empty `startTime` and `startDate` set to the day of the match, while `startsAt` and `startTimestamp` keep the placeholder; `dateFrom` and `dateTo` compare the day of the match. The check goes by the times on the page, so it can be wrong for an old league that really played at those hours. Records of the daily feeds and of the past year always have `startTimeKnown` true.

### Specific matches by ID

Paste Flashscore match IDs or match URLs into `matchIds` and get exactly those matches,
from any date the source keeps. Unknown IDs and matches of another
sport are listed in the run warnings and never billed. Match IDs
override `dateRange`, league URLs, teams and every competition and status filter;
`maxMatches`, `dateFrom` and `dateTo` still apply.

### Date ranges

Set `dateFrom` and `dateTo` (YYYY-MM-DD). On their own they pick days inside the
15-day window; together with league URLs, teams or match IDs they keep only matches in
that range, so a one-month slice of a season is billed as one month.

### Team and player search

Pass names instead of URLs — `teams: [a club name]` — and the Actor resolves
them through the source's own search, then pulls the **full known schedule and as much
history as you ask for** (`teamResultsPages`, about 40 matches per page; 50 pages of one NHL club reached back to January 2006).
Paste a Flashscore team or player link instead, for example
https://www.flashscore.com/team/<slug>/<id>/, or the 8-character ID from it, to skip the name search and get
exactly that team or player.

### Monitor mode: pay only for what changed

Turn on `monitorMode` and the Actor remembers every match it has already delivered in a
named key-value store **in your account**. The next run emits — and bills — **only new
and changed matches**: new fixtures, kickoff moves, score updates, line moves, final
results. On a schedule, an unchanged day costs only the run itself: the start event
and the **run minimum** (see the costs below), usually under half a cent a run.
Use `monitorName` to keep separate watchlists apart. `monitorKey`, the older name of the
same field, still works in saved tasks, schedules and API calls: the same value keeps the
same memory, and when both are set `monitorName` is used.

Every record the monitor emits says why: `changeType` is `new` for a match it has not
seen before and `changed` for one it has, and `monitorChangedFields` names what changed
— `score`, `status`, `schedule`, `odds`, `stats`, `summary`, `lineups` and so on — so a
kickoff move can be handled differently from a final score. `monitorChangedFieldsText`
holds the same list as one string ("score,status") for a spreadsheet column, and the
**Changes** view shows it next to `changeType`. A part that did not arrive because the
source failed
(`unavailableParts`) is not a change: the match is not output again for it, and when the
part comes back unchanged it is not billed again. With odds on, `odds.previous` holds the
average and best prices this monitor output last time, with the time, and `odds.changePct`
the change of each average price in percent.

**A changed match is billed for what changed, not for the whole record again.** It
costs the base event, plus the extras of the parts named in `monitorChangedFields`: a
moved line bills the odds, a fuller stat sheet bills the stats, while an unchanged
summary or lineup is not billed twice. Within `odds`, the three odds events are billed
separately: a moved match-winner price (pre-match or in-play) bills `match-odds`, a moved
total, handicap or both-teams-to-score line `match-odds-markets`, a changed price list
`match-odds-offers`. Row-billed parts — head-to-head rows, completed
games or periods of point by point, player lines, news articles — are billed only for
rows beyond those already paid for that match. The record itself still arrives complete.
Free parts do not make a match changed: a changed bookmaker stream alone does not output the match again, and a match output for another reason carries it up to date.

**What a monitor run checks.** In `monitorMode`, `maxMatches` caps the matches a run
checks, not only the ones it outputs. Matches that are over and started more than a day ago
are skipped without a request while they stay the same, and do not count toward it. When
more candidates are left than `maxMatches`, half of the checks (rounded up) go every run to
matches in play, matches past their start time, finals not output yet and then the top
competitions, and the other half to the rest, the ones checked longest ago first, so a big
watchlist is covered over several runs while the top matches are checked in every one.
The monitor remembers the unfinished matches of the first day it reads until their final is
output, including matches cut by `maxMatches` or by the status filter while they were in
play, and reads them again when they fall outside the selected days — from a feed already
read, or from the feed of the day they started, one request per day — first among the
checks, and billed only if they changed. Schedule it hourly with `dateRange`
`yesterday-today`, or daily with `last-3-days`, so a match that ends after midnight still
gets its final score; a monitor that reads a single day says so in its status. With
`matchStatuses` the monitor outputs only matches in those statuses: a monitor of `live`
matches only does not output final scores, so add `finished` to get them.

**One monitor per name at a time.** Do not run two monitors with the same `monitorName` at
once: both would output and charge the same changes. On a frequent schedule turn on the
schedule's `isExclusive` option, which skips a run while the previous one is still going —
that is the reliable protection. The Actor also tries to detect an overlap: a run that finds
the memory saved by another run of the same name within the last few minutes, while that run
is still going, outputs nothing and says why in its status. It relies on that run having
saved its memory midway (about once a minute while it changes something), so a short overlap
can pass unnoticed; then the two memories are merged at the end, and the status warns about
it.

### Regular loads: deduplicate and update

Every record carries `matchId`, the source's own ID, which stays the same across runs, and
`scrapedAt`, the moment the record was output. A match read twice — two runs over overlapping
days, or a monitor that emits it again after a change — comes as two records: keep the one with
the larger `scrapedAt`.

**Do not let a failed part overwrite good data.** When the source fails to send a part of a match
(statistics, summary, lineups, box score, head-to-head, odds and so on), the record names it in
`unavailableParts` (`unavailablePartsText` in the **Matches** and **Changes** views), leaves it
null and is not charged for it. A later record of the same match can carry such a gap where an
earlier one had the data, and in `monitorMode` the part that comes back unchanged is not output
again. So take the newest record and fill the parts it names from the latest earlier record that
has them. In pandas:

```python
import pandas as pd

df = pd.read_json("dataset.json").sort_values("scrapedAt")
latest = []
for _, rows in df.groupby("matchId", sort=False):
    row = rows.iloc[-1].copy()
    gaps = row.get("unavailableParts")
    for part in gaps if isinstance(gaps, list) else []:
        if part in rows and row[part] is None:
            filled = rows[part].dropna()
            if len(filled):
                row[part] = filled.iloc[-1]
    latest.append(row)
latest = pd.DataFrame(latest)
```

In a database or a sheet, upsert on `matchId` when the new `scrapedAt` is later, and for the
parts named in `unavailableParts` keep the stored value (`COALESCE(new.stats, old.stats)`).
`monitorMode` sends only new and changed matches, and the **Changes** view shows `changeType` and
`monitorChangedFieldsText` ("score,status") for each. `outputDatasetName` appends every record to
one named dataset across runs, so the history stays in one place; a named dataset is kept until
you delete it, Apify bills its storage, and it has no views (see **Getting a spreadsheet**).

### Compact output

Set `omitEmptyFields` to drop every null, empty list and empty object from the records.
Items inside lists keep their positions, so
period-by-period lists stay aligned. Leave it off for CSV and spreadsheet exports, where
a fixed set of columns is easier. With it off, records of runs with the same options have
the same keys; some keys appear only with their option or mode (`odds.markets`, `odds.offers`,
`odds.live`, the American prices, `odds.previous` and the other monitor fields, seeds), or only
when there is something to say (`unavailableParts`, a lifted box-score column the match has),
so a dataset that collects runs with different options has a varying set of keys.

### Betting odds

Turn on `includeOdds` and every match carries **match-winner prices from each bookmaker of
the countries you pick — opening price and current price side by side — plus the market
average, the best price and the bookmaker margin**. Finished matches keep their closing
prices, so already-played days can be backfilled.

The match-winner market is the one more bookmakers price: two-way including overtime and the shoot-out, or three-way in regular time. `odds.market` and `odds.scope` name it. In the NHL on the British storefront that is the two-way market including overtime (20 bookmakers on 13 October 2026, against 16 for three-way regulation time); the three-way market stays in the price list below.

Every odds record adds **no-vig probabilities** (`odds.impliedHome`, `odds.impliedDraw`, `odds.impliedAway`,
from 0 to 1), the **best price of each outcome with the bookmaker that offers it**
(`odds.bestHome` and `odds.bestHomeBookmaker`, and so on), the average **opening** price of the same
bookmakers (`odds.openingHome`...), how many bookmakers the averages cover (`odds.bookmakersCount`),
the book on the best prices (`odds.bestPriceOverround`, percent above 100) and `odds.arbitrage`,
true when the best prices of different bookmakers cover every outcome.

`odds.movement` shows where the match-winner market has gone since opening: for every
outcome, how many bookmakers shortened the price and how many drifted it.
`odds.movement.homeDirection` sums it up for the home side only, not for the whole market. It is
part of the match-winner odds and is not billed separately. Set `oddsFormat` to `american` and
averages, best prices, every bookmaker and in-play prices also come in American format
(`odds.homeAmerican`: +150, -110); decimal prices always stay.

Prices come from the odds feed behind Flashscore's own Odds tab, keyed by the same match id
and by the participant ids of the fixture, so a price never lands on the wrong team.
`oddsCountries` picks whose bookmakers you see. On 16 NHL games in runs on 13 and 14 October 2026 Great Britain listed 20 bookmakers each. The country is a
parameter of the request, not of your proxy, so the list is the same from any exit address,
Apify's US servers included. Pick several countries to **merge their bookmakers into one
record**: each bookmaker appears once with its country, and averages and best prices use the
merged list — Great Britain plus the United States gave 25 on NHL games. One request per country per match. All 17 countries on one
Ligue 1 match, read on 14 October 2026 (Ukraine listed none that day):

| `oddsCountries` | Storefront | Bookmakers | Among them |
|---|---|---:|---|
| `GB` | Great Britain | 20 | bet365, William Hill, Betfair, Paddy Power, Skybet |
| `US` | United States (New Jersey) | 5 | DraftKings, Fanduel, BetMGM.us, Fanatics, bet365.us |
| `IT` | Italy | 13 | Sisal, SNAI, Eurobet.it, GoldBet, Lottomatica |
| `ZA` | South Africa | 8 | Betway Africa, Sportingbet, Sunbet, Betxchange, Easybet |
| `FR` | France | 8 | Betclic.fr, Winamax, Unibet.fr, Pmu, bwin.fr |
| `SE` | Sweden | 7 | Unibetse, BetMGM.se, MrGreen, Coolbet, Expektse |
| `ES` | Spain | 6 | Codere, Sportium.es, Luckia.es, Retabet, Versus.es |
| `MX` | Mexico | 6 | Calientemx, 1xBet, Novibet, Sportium.mx, Winpot |
| `DE` | Germany | 6 | Interwetten, bwin, Betano.de, Oddset.de, Winamax.de |
| `PL` | Poland | 6 | STS.pl, eFortuna.pl, Betclic.pl, Superbet.pl, LV BET |
| `CA` | Canada (Ontario) | 5 | 888sport, TonyBet, PointsBet.ca, Sports Interaction, Betano.ca |
| `CZ` | Czech Republic | 4 | Tipsport.cz, iFortuna.cz, Betano.cz, Chance.cz |
| `NG` | Nigeria | 2 | Bet9ja, Betking |
| `NL` | Netherlands | 2 | bet365.nl, Sevelev.nl |
| `AR` | Argentina | 1 | Unibet |
| `AU` | Australia | 1 | Unibetau |
| `UA` | Ukraine | 0 | — |

**The default storefront of this Actor is the United States (New Jersey)**: with `oddsCountries` left as it is, the odds come from the New Jersey books in the table — DraftKings, Fanduel, BetMGM, Fanatics and bet365's New Jersey book. Prices stay decimal unless you set `oddsFormat` to `american`. For the British list, about 20 bookmakers, set `oddsCountries` to `["GB"]`, or `["US", "GB"]` to merge both.

**Prices of a match that has started are its closing prices.** The pre-match market closes
at the start, and the source keeps showing its last prices. On a match in play, finished,
interrupted, abandoned or canceled, `odds.priceState` is `closing`, every bookmaker and every
row of the price list carries `active: false`, and `odds.arbitrage` is null: a closed market
offers no bet to place. Upcoming and postponed matches carry `pre-match`. `odds.capturedAt`
says when the prices were read. When matches in play come with closing prices only, the run
status says so and points to the in-play prices under **Live matches**.

**Only matches with prices.** Set `onlyWithOdds` and a match no bookmaker prices is neither
returned nor billed; the run checks up to four times as many candidates as `maxMatches` to fill the rest.
When the odds feed fails for a match and there is no fallback, `odds` is null, the record names
`odds` in `unavailableParts` and the odds are not billed.

Turn on `includeOddsMarkets` as well and the record adds **over/under and Asian
handicap lines** (the puck line in the NHL) — the average price on each side and how many bookmakers stand behind
each line — and **both teams to score** where it is priced. Each block names its part
of the match in `scope`: of the two full-match variants, with and without overtime, the
one more bookmakers price.
It all comes from the same response as the match-winner prices, so there are no extra
requests — top competitions carry dozens of lines, minor ones a handful or none. A match with no total, handicap or both-teams-to-score line
gets `odds.markets` null and is not billed for markets.

**Every price, flat.** Turn on `includeOddsOffers` for every price of every bookmaker as a
flat list in `odds.offers`: market, part of the match, selection, line from the home side,
opening and current price, movement since opening and whether it is still offered. NHL games in runs on 13 and 14 October 2026 carried 1 802 to 1 924 rows in 10 markets, the first period included.
Filter by market (`oddsOfferMarkets`) and part of the match (`oddsOfferScopes`) to keep
records small. No extra request; charged once per match as `match-odds-offers`, only when
the list is not empty. The **Every price** view gives one row per price and **Bookmakers** one
row per bookmaker; the codes are listed under **Output fields**.

### Live matches

Turn on `includeLive` and matches in play carry their current state: **the running
score, the match clock and the stage of the match in play**. `live.minute` is the clock inside the current period, as Flashscore shows it, and `live.period` names the period.

Status comes in two fields: `status` is `scheduled`, `live`, `finished`, `postponed`,
`canceled`, `abandoned` (called off, no result) or `interrupted`
(stopped and not finished yet, so it can still resume and turn `finished`), and
`statusDetail` names the period — `1st period` to `3rd period`, `extra time` for overtime, `penalty shootout` — or how the game ended or stopped: `after extra time`, `after penalties`, `interrupted`, `suspended`. An interrupted game has neither `endedAt` nor `winner`, even when one side leads on goals: it is not over yet. A match whose start is held back stays `scheduled`, with `statusDetail`
`delayed` or `pending`.

Live state costs one request for the whole run, not one per match. Once a match is over,
`live` is null: the live feed keeps a finished match for a few minutes, and that tail is
neither returned nor billed as live state. `live.capturedAt` says when the state was read.
The live feed is read after the daily list, and its stage wins: with `includeLive` or the
**Live now** filter, `status`, `statusDetail` and the clock follow it, so a match that ended in
between comes as `finished`, with its winner and `endedAt`, and the **Live now** filter leaves it out.

**Only matches in play.** Set `matchStatuses` to **Live now** and the run returns just the
matches in play — the Actor reads the live feed first and filters before charging you, so you
don't pay for a full day of fixtures to find the handful that are on.
Matches in play can only be on today's or yesterday's list, so the run
reads just those days whatever `dateRange` says; **Finished** alone skips the days ahead.
The filter on its own does not add the live state: turn on `includeLive` as well for
the running score, the clock and the period, billed as `match-live`.

**In-play prices.** `includeLiveOdds` adds `odds.live` to matches in play: the average and best
in-play match-winner prices with the bookmaker behind each best price (`odds.live.home`,
`odds.live.bestHome`, `odds.live.bestHomeBookmaker`...), how many bookmakers the averages cover
and how many take bets right now (`odds.live.bookmakersCount`, `odds.live.activeBookmakers`), and
each bookmaker's own prices (`homePrice`, `drawPrice`, `awayPrice`), up to 10 bookmakers, one request
per bookmaker. Each bookmaker row also carries its first in-play price (`openingHome`...: the first
live price, not the pre-match one) and where the price stands against it (`homeDirection`,
`drawDirection`, `awayDirection`: `up` above it, `down` below it, null when equal), and the averages
count how many bookmakers hold each price above or below their first in-play price
(`odds.live.homeUpBookmakers`, `odds.live.homeDownBookmakers` and so on). How many bookmakers carry a live price depends on the match; not measured on this sport yet. It is part
of the odds charge.

The **Live state** view lists the matches in play with period, clock and live score.

### Round of the match

The daily feed carries a stage name for some competitions (`League phase`, `Play Offs`).
Switch on `resolveRounds` and the round is read from the tournament itself: **Final**,
**Semi-finals**, **1/8-finals**. The cost is per tournament in the run, not per match:

- League rounds such as **Round 6** and cup stages come from the tournament page (a heavy
  one, about 1 MB; free when the same league is in `leagueUrls`) plus the first page of
  its results feed for played matches and of its fixtures feed for upcoming ones: **two or
  three requests per tournament**, a further page only for a match deep in the season.
- **Matches in play are not looked up** and keep `round` null: the season feeds list a match
  before it starts and after it ends, not while it is on. A tournament with only matches in
  play costs nothing.
- Competitions the source does not split into rounds keep null too — the NHL regular season,
  for one — and the lookup stops after the first page.

### Input

```json
{
  "dateRange": "custom",
  "dayOffsets": [-1, 0, 1],
  "tours": ["USA: NHL"],
  "includeStats": true,
  "includeH2H": false,
  "maxMatches": 200
}
```

`dateRange` accepts presets (`today`, `yesterday`, `yesterday-today`, `last-3-days`, `next-week`, `whole-window`...);
with `dateRange: "custom"` the `dayOffsets` array is used instead: `0` today, `-1`
yesterday, `1` tomorrow. `dateFrom` and `dateTo` take calendar dates instead (see
**Date ranges** above).

**Day boundaries follow a time zone, and you choose it.** `timezone` takes a time zone name such as `America/New_York` or `Europe/Berlin`, with daylight saving applied, or an offset such as `+05:30`. Left empty, this Actor uses `America/New_York`, so `yesterday` is last night's American slate: on 14 October 2026 a `yesterday` run returned the full NHL slate of 13 October, 16 games. Set `UTC` or `Europe/London` for European leagues. A match is kept only if it starts on one of the chosen days in that zone: the source's day feeds overlap the neighbouring days by about an hour, and without this cut a daily run would bill such a match twice. A match in play that started yesterday still counts as today's. The older `timezoneOffset` (whole hours, no daylight saving) works when `timezone` is empty; its `0` means this Actor's default, so ask for UTC days with `timezone: "UTC"`.

The daily feeds reach **seven days back and seven days ahead** — measured on 13 October 2026 across offsets from -8 to +8: fixtures up to the seventh day, nothing on the eighth. `next-week` covers the seven days after today, so a weekday run reaches the weekend's fixtures. Offsets outside the window are dropped with a warning instead of
silently returning nothing.

`tours` keeps only competitions whose name contains one of the strings as whole words,
ignoring case, so `USA: NHL`, `KHL`, `SHL`, `Liiga` all work. The input form suggests 10 popular competitions written the way the source writes them (`USA: NHL`), and any other name can be typed in. Most of them are seasonal: a league between seasons has no matches, and when none of the selected competitions has any on the chosen days, the run status says why. Leave it empty for everything.

**More filters for the daily list.** `teamFilter` keeps only matches of the given teams or players among the selected days — names as whole words, or the 8-character IDs from Flashscore links, and `teamSide` (`home`, `away` or `any`) keeps only their home or away matches. `startsWithinHours` keeps matches that start from now until that many hours ahead, and days that cannot hold such matches are not read. These filters read nothing extra; for a team's history beyond the window use `teams`.

**Order and `maxMatches`.** `maxMatches` caps the run. When matches are picked by days (no league URLs, teams or match IDs), NHL games come first, then KHL, then the other 8 competitions suggested in the form, then the competitions Flashscore itself marks as top, then its main ones, then the rest; inside each group the source's order stays: day by day, countries alphabetically. `tours`, the status filter and dates narrow the set and keep this order.

**The order covers only the days the run reads.** Without filters, days are read four at a time, and reading stops once the days read hold `maxMatches` matches (with any filter every selected day is read). `whole-window` is read from today outward — today, yesterday, tomorrow, two days back, two days ahead and so on — so a small `maxMatches` gets the matches closest to today, and the far edges of the window are not read at all; the other presets are read day by day in calendar order. In `monitorMode` the order applies day by day, so a new fixture for tomorrow never pushes today's match out of the run, and `maxMatches` caps the matches checked: when there are more candidates, half of the checks go to matches in play, finals not output yet and the top of this order, the other half to the rest by rotation (see **Monitor mode**). League URLs, teams and match IDs keep the source's order.

### Run status, warnings and source failures

The run's status line says how the input was understood and how far the run got — for example "Reading days: 4 of 7 read, 3996 matches found" — and at the end what was output, with how many records carry each part, what `maxMatches` cut off and every warning about the input: a competition name with no match (with the closest names when it looks misspelt), a league URL that is not a league page or belongs to another sport, a team the search did not find. A run that comes back empty says why, and a run stopped by your spending limit says how much more the rest would need.

**A failure of the source is kept apart from missing data.** Days whose feed did not load, broken day feeds and league pages the source did not return are named in the status. So are the per-match feeds — statistics, summary, lineups, box score, head-to-head, news, highlights, broadcasts, odds — when they fail: such a record lists the missing parts in `unavailableParts` and is not billed for them. When the first batch of days does not load at all, the run fails at once instead of retrying the rest of the window, and it is charged only the start and the run minimum of a failed run.

**Long runs and limits.** While matches are processed the status shows the progress and about how long the rest will take, and warns when that runs past the run's timeout. Shortly before the timeout (15% of the run's time, but no less than 25 and no more than 45 seconds, and no more than half of it) the run stops starting new work: records already on their way are finished and charged, the status says how many matches were output, `RUN_SUMMARY` lists the rest in `notOutputMatchIds`, and the run ends on its own before the timeout. A second run with those IDs as `matchIds` gets just them, and none of them was charged in the first run. When your maximum cost per run cannot cover every selected match, the run says so before it starts on them: about how many it covers and what all of them would cost.

When there is something to explain, the same goes into the `RUN_SUMMARY` record of the run's key-value store — **Run summary** in the Output tab: `status` (`done`, `empty`, `stopped-early`, or `timing-out` when the run is about to reach its timeout), `scope`, `output`, `selected`, `unchanged`, `cutByMaxMatches`, `cutNotice`, `emptyReason`, `stopReason`, `warnings`, `sourceFailures`, `notOutputMatchIds`, and `runMinimumUsd` with `runMinimumNote` (the run minimum charged in the run). A clean run writes none. A monitor run writes it only when it stopped early, part of the source failed or it came back empty for a reason other than no changes: its input is the same in every scheduled run, so its input warnings stay in the status and the log.

### What a run actually costs

Billing is per event, and an event is charged only when the data actually exists. The
base event is **$0.00035 per match**, and every run starts with the `apify-actor-start`
event, $0.00005; the rows below add up the events each configuration actually fires:

| Run | What is billed | Per match |
|---|---|---:|
| Default — the input form ships this way | match, stat sheet and summary | **$0.00055** |
| Typical | plus odds and head-to-head at the default depth | **$0.00105** |
| Everything on | plus lineups, totals, handicaps and both teams to score, every bookmaker price, highlights, broadcasts, live state, the player box score | **$0.00565** |

**Per 1 000 matches:** scores only **$0.35**, the default settings **$0.55**, typical **$1.05**, everything on about **$5.65**. Apify's free plan includes $5 of usage a month ([pricing](https://apify.com/pricing)): about 9 000 matches at the default settings, or 14 200 with scores only.

The last row is arithmetic on row-billed items, so treat it as an order of magnitude rather than a fixed price:

- **head-to-head** — $0.00001 a row, **at most 30 billed rows a match** ($0.0003); rows beyond that come free in the same record. The default — 10 rows per side plus all direct meetings — holds about 75 to 95 rows; the source keeps up to about 75 meetings, so about 95 is the most. Measured on 12 and 13 October 2026: 93 and 95 rows on NHL games, 75 and 89 in the KHL. Clubs of one league meet several times every season, so most games land near 90. Setting `maxH2HRecords` to 0 adds up to about 50 form rows per side on top of the meetings, up to about 175 rows a match, still billed as 30. The row of the match itself is free and does not count.
- **box score** — $0.00005 a line. The count follows the roster that played; the table above prices it at 38 lines.
- **every bookmaker price** — $0.0019 a match, however many rows the list holds, and only when it is not empty.
- **news** — $0.00001 an article, and notable matches carry dozens. News is not counted in the table above.

Options you leave off cost nothing, and an option that returns no data is not billed at all: a fixture with no stat sheet, no odds and no box score is charged as a plain match, and a part that the source failed to deliver is not billed either. In monitor mode a changed match is billed only for the parts that changed (see **Monitor mode** above).

**A spending limit is kept record by record.** If you set a maximum cost per run, a match whose events no longer fit into what is left of it is not returned at all, so every record you receive is paid for in full and nothing arrives unbilled.

**Run minimum.** A run that reads a lot and returns little — a narrow filter over the whole 15-day window, a monitor run with nothing new, a competition between seasons — still uses compute, storage and traffic on the platform. Such a run is topped up with the `run-minimum` event, $0.00001 a unit, to a share of that usage at Apify's list prices (compute after the first 5 seconds, which Apify covers):

- a run that **succeeds** is topped up at the end to **1.5 times** its usage;
- a run that **fails, is aborted gracefully, or stops at or shortly before its timeout** is topped up to **1.25 times** its usage, which only covers the cost after Apify's 20% share. An abort without the graceful option (the default in Console and the API) stops the run at once, and it pays only what was charged along the way. A run still going 20 seconds before its timeout (half the time left in a short run) gets the top-up then, counting as paid only what the run will still charge in those seconds: records already on their way, and the matches its recent batches show it can still output at the base price — nothing while it is still reading the source, and for a monitor run only the records on their way. A run that finishes in time pays little or nothing for it, a run that the timeout cuts off pays it in full; whatever it charged is not refunded;
- a run longer than a minute charges part of it **as it goes**, after each batch of days, league, team or batch of matches, so that a crash halfway does not leave it unpaid. Matches already selected for output count toward it (a monitor run counts only what it has already output). This part is capped at 1.25 times the usage so far, counts toward the final top-up and is **not refunded** if later batches bring the data that would have covered the run;
- **users on the Apify free plan never pay it, and neither do Standby requests**, where you pay the platform usage directly.

Everything the run was charged, the start included, counts toward the minimum, so a run whose records cover its usage pays no final top-up, and a thin run usually comes to under half a cent in total. If you pick a residential proxy group yourself, its traffic counts at Apify's $8 per GB; the default datacenter proxy adds nothing of the kind. **The run status names the amount and the reason** (for example, from a local monitor run with nothing new: "Run minimum: $0.00008 was added, because the run's other charges ($0.00005) came to less than 1.5x the platform usage of this run (compute, storage and traffic, about $0.00009)"). The minimum never goes past your maximum cost per run: before each batch the run checks that the minimum for more work still fits and otherwise stops reading and says so, and a day feed that does not answer is not retried once the retry would no longer fit. The check runs between requests, so a single slow request can still take the run a little past what the limit covers; the top-up then stops at the limit.

### How this compares

| Capability | This Actor | Other Store Actors (14 Oct 2026, Apify Free plan prices) |
|---|---|---|
| Price per match, scores only | **$0.00035** | $0.0004 to $0.0335 a game on 14 other Actors |
| Price per match with detail | **$0.00055** at the default settings, **$0.00565** with every option on | $0.002 to $0.0845 a game with details on 11 other Actors |
| Small request (13-14 Oct 2026 build) | **4.4 s** (3 games by ID with stats, summary and box score) | 1.6 to 2.4 s for 2 to 3 NHL games on three ESPN-based Actors |
| Team statistics | **NHL 18 metrics per game, per period and for overtime**; KHL 17 to 18 | 14 per game, without periods, on one ESPN-based Actor |
| Player box score | **37 to 39 players, 16 columns** with TOI, shots, hits and faceoffs; goalkeepers with saves and SV% | 21 skater and 12 goalkeeper columns on one ESPN-based Actor |
| Head-to-head with a form summary | **10 games per side by default (up to 50) and all meetings**, cut at the match date; form, rest days, home and away | season series only on one; form up to 8 and meetings up to 12 on another; up to 100 games per team from stored snapshots on a third |
| Bookmaker odds | **20 British bookmakers on NHL games, 25 with the US merged in**; 1 802 to 1 924 prices in 10 markets on request | one DraftKings moneyline, puck line and total on one ESPN-based Actor; a single line or bookmaker on seven more; 1X2 by bookmaker on one Flashscore-based Actor |
| Day boundary | **America/New_York by default**, any IANA time zone | a selectable output time zone on one other Actor |
| Monitor mode: pay only for changes | **yes**, billed only for the parts that changed | change-only modes on three other Actors |
| Standby HTTP API | **yes**, up to 100 games a request | one other Actor; an MCP path on one ESPN-based Actor |
| Proxies | **datacenter, no extra cost** | none on the ESPN-based Actors; residential by default on one SofaScore-based Actor |

### How the data is checked

- **Odds are joined by the shared match id and participant ids**, never guessed from
  names and kickoff times, so a price cannot land on the wrong fixture or team.

### Speed and compute

Measured on the Apify platform on 13 and 14 October 2026 with 1024 MB of memory; the times
include starting the container. Rows with a build number ran on that build on 14 October; the others ran on earlier builds, which did not yet read the whole window from today outward or write the records of a batch in the order the matches were selected, nor read a lineup per played match for the box score (not billed). Builds after 1.0.3 were not timed: they start Node directly without the crawlee package and set up the proxy only when a request needs it. Take the times as the order of magnitude. Compute units are what a run uses on the platform. In a
regular run you do not pay for them on top of the events, except through the run minimum of
a thin run; in Standby you pay the platform usage directly:

| Run | Matches | Time |
|---|---:|---:|
| Three games by ID with stats, summary and box score | 3 | 4.4 s |
| Three NHL games, every block on | 3 | 8.5 s |
| Sixteen NHL games, every block on | 16 | 9.1 s |
| One day of every league, stats and summary | 64 | 5.8 s |
| The Store test input on build 1.0.3: the whole window, the form as it ships | 20 | 6.9 s |

Each detail option adds about one request per match, and requests run in parallel, so the time grows slower than the number of matches. The data is billed per event, not per second.

No browser, no API key, plain HTTP requests. The default Apify datacenter proxy is
enough, so no residential-proxy cost ends up in the price.

### Output fields

The dataset schema of this Actor (its `fields` section, which the API returns with the run's dataset and MCP clients and code generators read) describes every field with its type and meaning, many of them with an example. Every type can also be null. Percentages run from 0 to 100 (`homePercent`, `margin`, `bestPriceOverround`), probabilities from 0 to 1 (`impliedHome`, `impliedDraw`, `impliedAway`); `odds.changePct` is a change in percent and can be negative or above 100. In box scores `rawStats` and the columns lifted from it give shares as fractions from 0 to 1 (`passes_accuracy` 0.54, `save_percentage` 0.865), while `stats` holds numbers as the source shows them, shares from 0 to 100 (`SV%` 86.5 in `stats`, 0.865 in `rawStats`). `live.minute` is text as the source shows it ("45+2", "HT"), with `live.minuteNumber` and `live.addedTime` next to it; event minutes have `minuteNumber`, which in hockey is the time within the period, so sort by period first. Times are ISO 8601 in UTC (`startsAt`, `scrapedAt`, `capturedAt`), except `startDate` and `startTime`, which are in the run's time zone.

**Top-level fields**

| Field | Type | Meaning |
|---|---|---|
| `matchId` | string | Flashscore match ID, 8 characters. Stable across runs: the key to deduplicate, update and join records. |
| `sport` | string | Sport slug, such as football, tennis, basketball, hockey, baseball, handball or table-tennis. |
| `startDate` | string | Start date in the run's time zone (see timezone), YYYY-MM-DD. |
| `startTime` | string | Start time in the run's time zone, HH:MM; null when startTimeKnown is false. |
| `startTimeKnown` | boolean | False for an archive record the source keeps without a start time, only the day (a page older than a year with at least 90% of its games at midnight in Europe or UTC): startTime is then null and startDate is the day of the match, while startsAt and startTimestamp keep the source's placeholder time. True for the daily feeds and the past year. |
| `timezone` | string | Time zone of startDate and startTime: the timezone input or this Actor's default, as an IANA name or a UTC offset. |
| `homeTeam` | string | Name of the home side (player 1 in tennis); the same as home.name. |
| `awayTeam` | string | Name of the away side (player 2 in tennis); the same as away.name. |
| `homeScore` | number | Score of the home side as a number: sets won in tennis and other set sports, goals, points or runs elsewhere, without a penalty shoot-out. Null before the start. |
| `awayScore` | number | Score of the away side as a number, as homeScore. |
| `tournament` | string | Competition as the source writes it: COUNTRY: League - Stage; in tennis TOUR - EVENT: Tournament (Country), surface. |
| `tournamentName` | string | Short name of the competition: without country and stage, in tennis without tour and surface. |
| `tournamentId` | string | Stable ID of the competition at the source: the key to its season archives. |
| `tournamentSeasonId` | string | ID of the competition season, under the same name in the League Standings Actor. Null where the competition has no seasons. |
| `tournamentStageId` | string | ID of the season stage, under the same name in the League Standings Actor. |
| `tournamentLogoUrl` | string | URL of the competition logo. |
| `tournamentCountry` | string | Country or region of the competition as the source names it. |
| `countryId` | number | The source's numeric ID of the competition country or region. |
| `categoryId` | number | The source's numeric ID of the section the competition belongs to: a country for team sports, a category such as ATP - Singles in tennis. |
| `round` | string | Round or stage: from the daily feed where it carries one (League phase, Final), otherwise only with Match round (resolveRounds). |
| `tournamentUrl` | string | Page of the competition at the source. |
| `startsAt` | string | Scheduled start, ISO 8601 in UTC. |
| `startTimestamp` | number | Scheduled start, Unix seconds. |
| `endedAt` | string | When the match ended, ISO 8601 in UTC: only for finished and abandoned matches. |
| `status` | string | scheduled, live, finished, postponed, canceled, abandoned (called off, no result) or interrupted (stopped, may still resume). |
| `statusDetail` | string | Period in play (1st half, 2nd set, 3rd period) or how the match ended or stopped (after extra time, after penalties, retired, walkover, awarded, delayed). |
| `home` | object | Home side (player 1 in tennis): name, IDs, links and, in tennis, ranking and seed. |
| `away` | object | Away side (player 2 in tennis), as home. |
| `score` | object | Final or current score: sets won in set sports, goals, points or runs elsewhere; after a shoot-out the score of play without it. |
| `scoreText` | string | Ready-made score: "6-7(3), 4-6" in tennis, "2-1" or "1-1 (pen. 4-3)" elsewhere, " ret." after a retirement, "w/o" for a walkover. |
| `creditedScore` | object | The source's credited result: after a penalty or shoot-out series the winner of the series gets one more (3-3 in play becomes 4-3). Equals score otherwise. Only in sports with shoot-outs. |
| `periodScores` | array of object | Score of every period with its number: halves, quarters, periods or innings. |
| `regulationScore` | object | Score after regulation time (90 minutes, three periods, nine innings); differs from score after overtime or a shoot-out. |
| `overtimeScore` | object | Score after overtime or extra innings, before a shoot-out; null without overtime. |
| `penaltyShootout` | object | Result of the penalty or shoot-out series; null without one. |
| `note` | string | The source's note: first-leg result, series standing, postponement reason. |
| `winner` | string | home or away; null for a draw, an unfinished or interrupted match. |
| `winnerName` | string | Name of the winner as the source writes it. |
| `matchUrl` | string | Page of the match at the source. |
| `odds` | object | Match-winner odds with averages, best prices and every bookmaker; markets, every price and in-play prices with their options. Needs Match-winner odds (includeOdds). |
| `live` | object | State of a match in play; null otherwise. Needs Live state (includeLive). |
| `summary` | object | Venue, attendance, referees, score and duration by period, and the event timeline. Needs Match summary (includeSummary). |
| `lineups` | object | Starters, substitutes and coaches, missing players and predicted lineups. Needs Lineups (includeLineups). |
| `stats` | object | Team statistics per match and per period. Needs Match statistics (includeStats). |
| `playerStats` | object | A line per player. Needs Player box score (includePlayerStats). |
| `h2h` | object | Recent matches of each side, direct meetings and a form summary per side. Needs Head-to-head history (includeH2H). |
| `news` | array of object | Articles linked to the match. Needs Match news (includeNews). |
| `highlights` | array of object | Video highlight links, after the match. Needs Video highlights (includeHighlights). |
| `broadcasts` | array of object | TV channels and streams listed by the source. Needs TV and streaming listings (includeBroadcasts). |
| `scrapedAt` | string | When the record was output, ISO 8601 in UTC. The later of two records of one match is the newer one. |
| `unavailableParts` | array of string | Parts that were asked for but did not arrive because the source failed (stats, summary, lineups, playerStats, pointByPoint, h2h, news, highlights, broadcasts, odds); they are not billed. odds is also listed when one of several oddsCountries did not answer: the record then carries the other countries' bookmakers, and its odds are not billed. Absent when nothing failed. |
| `unavailablePartsText` | string | unavailableParts joined with commas, for one spreadsheet column (the Matches and Changes views). Absent when nothing failed. |
| `changeType` | string | new or changed: why the monitor emitted the match. Monitor mode only. |
| `monitorChangedFields` | array of string | Groups that changed since the monitor last emitted the match: score, status, schedule, odds, stats, summary, lineups and others. Null for a new match. Monitor mode only. |
| `monitorChangedFieldsText` | string | monitorChangedFields joined with commas, for one spreadsheet column. Null for a new match. Monitor mode only. |

**Fields of the nested parts** (their descriptions are in the dataset schema):

- `home` and `away`: `name`, `slug`, `id`, `country`, `code`, `imageUrl`, `countryId`, `url`, `currentRank`, `currentRankPoints`, `currentRankDate`, `seed`, `seedNumber`, `entryType`, `partner`
- `odds`: `home`, `draw`, `away`, `bestHome`, `bestDraw`, `bestAway`, `bestHomeBookmaker`, `bestDrawBookmaker`, `bestAwayBookmaker`, `openingHome`, `openingDraw`, `openingAway`, `bookmakersCount`, `margin`, `impliedHome`, `impliedDraw`, `impliedAway`, `homeAmerican`, `drawAmerican`, `awayAmerican`, `bestHomeAmerican`, `bestDrawAmerican`, `bestAwayAmerican`, `bestPriceOverround`, `arbitrage`, `priceState`, `capturedAt`, `market`, `scope`, `source`, `country`, `countries`, `liveBookmakerIds`, `bookmakers`, `movement`, `markets`, `offersCount`, `offers`, `live`, `previous`, `changePct`
- `odds.bookmakers[]` (the **Bookmakers** view): `bookmaker`, `bookmakerId`, `homePrice`, `drawPrice`, `awayPrice`, `openingHome`, `openingDraw`, `openingAway`, `active`, `country`, `homeAmerican`, `drawAmerican`, `awayAmerican`
- `odds.offers[]` (the **Every price** view): `bookmakerId`, `bookmaker`, `country`, `market`, `scope`, `selection`, `line`, `unit`, `price`, `opening`, `movement`, `active`
- `odds.markets.overUnder[]` (the **Totals** view): `line`, `over`, `under`, `unit`, `bookmakers`, `scope`
- `live`: `liveScore`, `minute`, `minuteNumber`, `addedTime`, `period`, `clockStale`, `capturedAt`
- `odds.live`: `market`, `scope`, `home`, `draw`, `away`, `bestHome`, `bestDraw`, `bestAway`, `bestHomeBookmaker`, `bestDrawBookmaker`, `bestAwayBookmaker`, `homeAmerican`, `drawAmerican`, `awayAmerican`, `bestHomeAmerican`, `bestDrawAmerican`, `bestAwayAmerican`, `bookmakersCount`, `activeBookmakers`, `homeUpBookmakers`, `homeDownBookmakers`, `drawUpBookmakers`, `drawDownBookmakers`, `awayUpBookmakers`, `awayDownBookmakers`, `bookmakers`, `fetchedAt`
- `summary.events[]` (the **Events** view): `minute`, `minuteNumber`, `addedTime`, `shootoutOrder`, `side`, `type`, `player`, `playerId`, `playerUrl`, `assist`, `assistIds`, `playerIn`, `playerInId`, `note`, `reason`, `comment`, `scoreAfter`, `disallowed`, `period`, `periodName`
- `lineups.home[]` and `lineups.away[]` (the **Lineups** views): `number`, `numberInt`, `name`, `surname`, `playerId`, `playerUrl`, `position`, `positionShort`, `country`, `rating`, `ratingRank`, `substitutedAtMinute`, `substitutedAtMinuteNumber`, `substitutedBy`, `cameOnAtMinute`, `cameOnAtMinuteNumber`, `replaced`, `incidents`, `role`, `formationPlace`, `captain`, `imageUrl`, `heightCm`, `age`, `marketValueEur`, `seasonRating`, `roles`
- rows of `stats.scopes[].groups[].items[]` (the **Statistics** view): `name`, `homeText`, `awayText`, `isHeadline`, `key`, `homeValue`, `awayValue`, `homePercent`, `homeMade`, `homeAttempts`, `awayPercent`, `awayMade`, `awayAttempts`
- `playerStats.players[]` (the **Players** view): `name`, `playerId`, `playerUrl`, `country`, `number`, `numberInt`, `teamCode`, `team`, `position`, `stats`, `starter`, `rating`, `topRated`, `rawStats`
- rows of `h2h.homeRecent[]`, `h2h.awayRecent[]` and `h2h.meetings[]` (the **Head-to-head** view): `section`, `playedAt`, `meetingId`, `surface`, `meetingTournament`, `country`, `homeName`, `awayName`, `result`, `score`, `scoreRegulation`, `penalties`, `url`, `tournamentCode`, `homeId`, `awayId`, `homeSlug`, `awaySlug`, `homeCountry`, `awayCountry`, `homePartner`, `awayPartner`, `meetingHomeScore`, `meetingAwayScore`, `scoreAfterExtraTime`, `meetingWinner`, `meetingStatus`, `resultDetail`, `side`, `advanced`, `stageWon`, `tabs`, `isThisMatch`, `isAfterThisMatch`

Every box-score line also carries its main numbers as columns of their own, so the **Players** view shows them: `goals`, `assists`, `points`, `plus_minus_points`, `penalties_in_minutes`, `time_on_ice`, `shots_on_goal`, `hits`, `blocked_shots`, `giveaways`, `takeaways`, `faceoffs`, `faceoffs_won`, `faceoffs_pct`, `goalkeeper_saves_made`, `goalkeeper_saves_attempts`, `save_percentage`. They are the values of `rawStats` under readable names; other columns of the source come the same way under their snake_case names.

**Market and part codes** of `odds.market`, `odds.scope` and the price list (`oddsOfferMarkets`, `oddsOfferScopes`); a selection is `home`, `draw`, `away`, `over`, `under`, `yes`, `no`, a score such as `2:1` or a pair such as `home/draw`:

| Market | Meaning |
|---|---|
| `HOME_DRAW_AWAY` | Match winner with draw (1X2) |
| `HOME_AWAY` | Match winner, no draw |
| `OVER_UNDER` | Over/Under |
| `ASIAN_HANDICAP` | Asian handicap |
| `EUROPEAN_HANDICAP` | European handicap |
| `DOUBLE_CHANCE` | Double chance |
| `DRAW_NO_BET` | Draw no bet |
| `CORRECT_SCORE` | Correct score |
| `HALF_FULL_TIME` | Half-time/Full-time |
| `ODD_OR_EVEN` | Odd/Even |
| `BOTH_TEAMS_TO_SCORE` | Both teams to score |
| `TO_QUALIFY` | To qualify |
| `NEXT_GOAL` | Next goal |
| `TOP_POSITION_MERGED` | Top position |
| `TO_WIN_AND_TOP_POSITION` | To win and top position |
| `WIN_EACH_WAY` | Win each way |

| Part | Meaning |
|---|---|
| `FULL_TIME` | Full time |
| `FULL_TIME_OVER_TIME` | Full time including overtime |
| `FIRST_HALF` | 1st half |
| `SECOND_HALF` | 2nd half |
| `FIRST_PERIOD` | 1st period |
| `FIRST_QUARTER` | 1st quarter |
| `FIRST_SET` | 1st set |
| `SECOND_SET` | 2nd set |

### Hockey API: call it from code

The **API** tab of this page has ready code with this Actor's full name and your token; below,
`<owner>` stands for the owner part of that name. Called without `maxMatches` and `dateRange`, the
API takes up to 200 of today's matches with statistics and summary, as the input schema defaults
say, while the form starts with 20 over the whole window: set both in code. `maxTotalChargeUsd`
caps what a run may charge you, and a run that ends without `SUCCEEDED` has its reason in
`statusMessage`. A synchronous call waits for the run and returns the dataset items; a run longer
than 300 seconds answers with 408, so keep synchronous calls small and use a client library for big
runs. Ask the dataset for the fields you need (`fields`) or a view (`view`) to keep responses small.

```bash
curl -X POST "https://api.apify.com/v2/acts/<owner>~ice-hockey-scraper-nhl/run-sync-get-dataset-items?maxTotalChargeUsd=0.50&fields=matchId,startDate,homeTeam,awayTeam,homeScore,awayScore,status" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"dateRange": "yesterday", "tours": ["USA: NHL"], "includeOdds": true, "maxMatches": 20}'
```

Python, with [apify-client](https://docs.apify.com/api/client/python/docs):

```python
from decimal import Decimal
from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("<owner>/ice-hockey-scraper-nhl").call(run_input={"dateRange": "yesterday", "tours": ["USA: NHL"], "includeOdds": True, "maxMatches": 20}, max_total_charge_usd=Decimal("0.50"))
status = getattr(run.status, "value", run.status) if run else None
if status != "SUCCEEDED":
    print("Run did not succeed:", status, run.status_message if run else "")
else:
    for item in client.dataset(run.default_dataset_id).iterate_items(fields=["matchId", "startDate", "homeTeam", "awayTeam", "homeScore", "awayScore", "status"]):
        print(item)
```

JavaScript, with [apify-client](https://docs.apify.com/api/client/js/docs):

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('<owner>/ice-hockey-scraper-nhl').call({"dateRange": "yesterday", "tours": ["USA: NHL"], "includeOdds": true, "maxMatches": 20}, { maxTotalChargeUsd: 0.5 });
if (run.status !== 'SUCCEEDED') {
    console.error('Run did not succeed:', run.status, run.statusMessage);
} else {
    const { items } = await client.dataset(run.defaultDatasetId).listItems({ fields: ['matchId', 'startDate', 'homeTeam', 'awayTeam', 'homeScore', 'awayScore', 'status'] });
    for (const item of items) console.log(item);
}
```

### Use it from an MCP client

Apify's MCP server at `https://mcp.apify.com` exposes Store Actors as tools to MCP clients
such as Cursor, VS Code or GitHub Copilot. The client can find this Actor with the
`search-actors` tool and run it with `call-actor`, or you can pin it as a tool of its own:
`https://mcp.apify.com?tools=<owner>/ice-hockey-scraper-nhl`, with the full name from the **API** tab. Sign in
with OAuth or send your Apify token as a Bearer header. The server skips Actors that need full
account access; this one does not need it. The dataset schema describes every output field for the
agent (see **Output fields**). Details: [Apify MCP server](https://docs.apify.com/mcp).

### Standby HTTP API

The Actor also runs in Apify Standby mode: send a GET request and the matches come back as JSON
in the response. Take the address from the **Endpoints** tab of this Actor's page and send your
Apify token in the `Authorization: Bearer` header. After a quiet spell a request can take a few
seconds more while the platform starts a Standby run, and if no run becomes available within 2
minutes the platform answers 429 ([Apify docs](https://docs.apify.com/platform/actors/running/standby)).

```
GET <address from the Endpoints tab>/?dateRange=today&maxMatches=25
```

Query parameters mirror the input fields, within these limits per request: `maxMatches` 25 by
default and 100 at most, one league URL, three teams and two history pages per team.
`monitorMode`, `monitorName` (or its older name `monitorKey`), `outputDatasetName` and
`proxyConfiguration` work only in regular runs: a request with them returns every selected match
and names them in `warnings`. Lists (`tours`, `teams`, `matchIds`, `oddsCountries`,
`matchStatuses` and others) take a repeated key (`tours=A&tours=B`), a JSON array
(`tours=["A","B"]`) or commas; a value with a comma inside needs one of the first
two. A misspelt parameter
or an invalid value answers 400 with `{"error": {"type", "parameter", "message", "allowed",
"runId"}}` and charges nothing; 405, 500 and 502 use the same body.

**What a request costs.** Every response bills every match it returns, as a regular run would:
there is no monitor in Standby. On top of the events you pay the platform usage of the Standby run
("you pay the platform usage costs of the Standby runs in addition to the event charges", Apify
docs): with this Actor's Standby settings it runs with 1 GB of memory and stays up for 300 seconds
after the last request. The run minimum is not charged in Standby. A poll for in-play prices:

```
GET <address>/?matchStatuses=live&includeLive=true&includeOdds=true&includeLiveOdds=true&includeStats=false&includeSummary=false
```

costs $0.00075 a match per request (result, live state and odds); 10 matches every 30 seconds
for two hours, 240 requests, come to $1.80 in events plus the platform usage.

The response is JSON with `count`, `tookMs` and `matches` (records in the same shape as the
dataset), plus `warnings` when something was trimmed, ignored or came back empty. When fewer
matches arrive than were selected, the response adds `truncated: true`, `selected` (how many
were selected for output) and `reason`: `charge limit reached` (the run's maximum total charge is
spent), `charging unavailable` or `some matches could not be processed`. With `onlyWithOdds`,
`selected` counts the slots, up to `maxMatches`, and `scanned` how many candidates were checked
for odds; when fewer matches with odds were found than `maxMatches` while unchecked candidates
remained, the reason is `not enough matches with odds among those checked`. Once the charge limit
is spent, every further request answers at once, without scraping, with `count: 0`,
`reason: "charge limit reached"`, a warning and an empty `matches` list. When part of the source
did not answer, the response adds `sourceFailures`: the days whose feed did not load (`days`),
broken day feeds (`brokenDays`), league pages (`leagues`), parts of records by name and count
(`parts`) and matches skipped on an error (`matches`); the records themselves name the missing
parts in `unavailableParts`. When no requested day could be read, the request answers 502.

### Schedules, webhooks and integrations

Everything the Apify platform offers around a run works here as for any other Actor:

- **[Schedules](https://docs.apify.com/actors/running/schedules)** run the Actor or a saved
  task on a cron expression with a fixed input. Pair a schedule with `monitorMode` and each run
  delivers only what changed since the last one; set the schedule's `isExclusive` option so a
  new run does not start while the previous one is still going.
- **[Webhooks](https://docs.apify.com/integrations/webhooks)** call your URL when a run
  succeeds or fails, so a pipeline can pick up the dataset the moment it is ready.
- **[Google Drive](https://docs.apify.com/integrations/drive)** uploads a file of the results
  after each successful run (see the FAQ for a Google Sheet).
- **[Slack](https://docs.apify.com/integrations/slack)** posts run status to a channel;
  **[Zapier](https://docs.apify.com/integrations/zapier)**,
  **[Make](https://docs.apify.com/integrations/make)** and
  **[n8n](https://docs.apify.com/integrations/n8n)** pass the results to other apps without code;
  **[Airbyte](https://docs.apify.com/integrations/airbyte)** loads datasets into a warehouse.

Cron expressions for the usual jobs (UTC; the schedule takes a time zone of its own):

| Job | Cron | Input |
|---|---|---|
| Yesterday's results every morning | `0 10 * * *` | `dateRange: "yesterday"`, 10:00 UTC is 6:00 in New York in summer |
| Results and fixtures every hour with monitor mode | `0 * * * *` | `dateRange: "yesterday-today"`, `monitorMode: true` |
| Matches in play every 5 minutes | `*/5 * * * *` | `matchStatuses: ["live"]`, `includeLive: true`, `monitorMode: true` |
| The week's odds every Monday | `0 7 * * 1` | `dateRange: "next-week"`, `includeOdds: true` |

### Good for

- **Betting and in-play models** — shots, saves and power play per period, not just the final score.
- **Fixture and result feeds** across NHL and European leagues in one dataset.
- **Season archives** — schedule a daily run and accumulate results.

### Limits

- **Odds follow the bookmaker market.** Upcoming, live and finished matches carry prices — finished ones keep the closing line. Minor divisions and fixtures far
  ahead may carry no prices at all — that is the market, not the scraper. Each country has
  its own licensed bookmakers, so the list changes with `oddsCountries` (pick several to
  merge them).
- **A fallback odds source covers the gaps, with less detail.** When the main odds feed has
  no price for a match, the Actor tries BetExplorer for ice hockey. Those records carry
  the market average and best price only — no per-bookmaker table and no markets — and from
  Apify's US servers that source shows far fewer bookmakers. `odds.source` tells which one
  you got.
- **Stats exist only for matches that have started.** Fixtures for tomorrow return teams
  and kickoff time but an empty stat sheet — that is the source, not the scraper.
- Coverage follows Flashscore itself: strong for top competitions, thinner for minor ones.
- **Team stats come only where the league publishes them.** Of the 64 games of 13 October 2026, 35 had a stat sheet: NHL games the full set of 18 metrics, the KHL 17 to 18, the Champions Hockey League 15, the VHL and MHL 14. AHL games of 12 October had a shorter sheet without hits and faceoffs, while junior leagues (OHL, QMJHL, WHL, NCAA) and several smaller European leagues had none; the off-season friendlies checked in August had none either. The per-skater box score is a separate option.
- **Hockey values come as the source publishes them.** Power plays count offsetting penalties as chances: 1 of 4 (25%) on an NHL game checked against ESPN in October 2026, where ESPN shows 1 of 3 (33.3%). A goalkeeper's save percentage can read 82.40% for 14 saves of 17 shots, and past seasons carry today's club names: the 2005-06 NHL season lists the Winnipeg Jets and the Arizona Coyotes, then the Atlanta Thrashers and the Phoenix Coyotes.
- **Head-to-head of older matches is thin.** The source publishes recent form as of today, and rows played after the match are dropped, not replaced, so the older the match, the less recent form it keeps. Direct meetings played before the match stay.

### FAQ

**How do I get NHL box scores through an API?**
Turn on `includePlayerStats`: a line for every skater with goals, assists, points and penalty minutes, with player ids. Team stats per match and per period (shots on goal, saves, power play, faceoffs, hits) come with `includeStats`, on by default.

**Can I call it as a hockey API from Python or JavaScript?**
Yes. Every Actor on Apify is also an HTTP API: a synchronous call returns the matches as JSON in one request, and the Apify clients for Python and JavaScript run bigger loads; see **Hockey API: call it from code**. Results also export to CSV and Excel.

**Do I need a Flashscore account, an API key or residential proxies?**
No. The Actor reads public feeds with plain HTTP requests, and the default Apify datacenter proxy is enough.

**How far back and ahead does it go?**
The daily feeds cover seven days back and seven ahead. Older matches come through `leagueUrls` (season archives), `teams` (a team's or player's history) or `matchIds`, which finds a single match on any date.

**Why do some matches come without statistics, lineups or odds?**
For two reasons. Flashscore publishes these parts only for some competitions, and bookmakers do not price every fixture: a part that does not exist is not billed, and `onlyWithOdds` leaves unpriced matches out altogether. Or the source failed to deliver the part during the run: it is not billed either, and the record names it in `unavailableParts`, odds included (see **Run status, warnings and source failures**).

**Why did my run return nothing, or fewer matches than I expected?**
The run status says why: no matches on the chosen days, a competition name with no match (with the closest names when it looks misspelt), a league URL that is not a league page, the `maxMatches` cut, the spending limit, the timeout, or days and parts the source did not deliver. The same explanation goes into `RUN_SUMMARY` in the run's key-value store (**Run summary** in the Output tab).

**How do I get a spreadsheet?**
In the run's **Output** tab press **Export**, set **View** to **Matches** (or another table) and the format to **Excel**. The default **All fields** keeps the score and teams in CSV and Excel, but scattered among about 2,000 columns and with most nested data cut off. Through the API add `view=overview&format=xlsx` to the dataset items URL; see **Getting a spreadsheet**.

**How do I get the results into a Google Sheet?**
Once: download the Excel file as above and open it with Google Sheets. After every run: the [Google Drive integration](https://docs.apify.com/integrations/drive), on the Integrations tab of the Actor or of your saved task, uploads the results to your Drive after each successful run, and [Zapier](https://docs.apify.com/integrations/zapier) or [Make](https://docs.apify.com/integrations/make) can pass them on to Google Sheets.

**How do I keep a dataset current without paying twice for the same match?**
Schedule the Actor with `monitorMode` on: each run emits only new and changed matches, and `changeType` and `monitorChangedFields` say what changed. A changed match is billed the base event plus only the parts that changed, and row-billed parts only for rows not paid for before. Keep the latest record of each `matchId`, and fill the parts it names in `unavailableParts` from the earlier record (see **Regular loads**).

**What happens when a run reaches my spending limit?**
When the limit cannot cover every selected match, the run says so first: about how many matches it covers and what all of them would cost. A match whose events no longer fit into what is left of the limit is not returned, so you never receive a record that was not paid for in full. The run status names the limit and how much more the rest would need, and `RUN_SUMMARY` lists the IDs of the matches not output (`notOutputMatchIds`): pass them as `matchIds` to get just those.

**Which time zone are the dates in?**
`startDate` and `startTime` are in the run's time zone, named in `timezone` (empty input means America/New_York in this Actor), and that zone also decides where one day ends for `dateRange`, `dateFrom` and `dateTo`. The other timestamps (`startsAt`, `startTimestamp`, `scrapedAt`) are UTC.

**What does a typical match cost?**
$0.00055 at the default settings; the cost table above shows the other configurations and the price of 1 000 matches. Options you leave off cost nothing.

**Why does a run that returned little show a `run-minimum` charge?**
Reading the source costs compute, storage and traffic even when your filters keep two matches or none. Such a run is topped up to 1.5 times what it used on the platform, or 1.25 times when it fails, is aborted gracefully or reaches its timeout; see **Run minimum** under the costs. The run status names the amount. A run whose records cover its usage pays no final top-up, a run longer than a minute may have paid part of it along the way, and users on the Apify free plan and Standby requests never pay it.

**Will the output change under my pipeline?**
This is version 1.1. A removed field, a field renamed without its old name still working, or a changed default comes with a new major version and a line in the changelog on the Information tab. To stay on one build, pass its build number (such as `1.0.3`, shown in the run's details) as `build` when you start a run through the API.

### Related Actors

- [League Standings & Top Scorers Scraper — ATP, WTA](https://apify.com/scapypuppy/league-standings-top-scorers) — league tables and top scorers of the same competitions under the same `tournamentId`, `tournamentSeasonId` and `tournamentStageId`; its `teamId` equals `home.id` and `away.id` here.
- The same record for other sports: [Tennis Scraper — ATP, WTA | Live Scores, Odds, Point by Point](https://apify.com/scapypuppy/tennis-scraper-atp-wta); [Football & Soccer Scraper — xG, Live Scores, Odds](https://apify.com/scapypuppy/football-soccer-scraper); [Basketball Scraper — NBA, EuroLeague, Box Scores](https://apify.com/scapypuppy/basketball-scraper-nba); [Baseball Scraper — MLB Box Scores, NPB, KBO, Odds](https://apify.com/scapypuppy/baseball-scraper-mlb); [Multi-Sport Scraper — Table Tennis, Handball +21](https://apify.com/scapypuppy/multi-sport-scraper).

### Support

Open an issue in the **Issues** tab of this Actor's page. Paste the link to the run (from the
run's page in Console): an Actor issue with a run link shares that run, its input, log and
storages with the developer automatically, and nothing else in your account
([Apify docs](https://docs.apify.com/account/collaboration/general-resource-access)). Say what
you expected and what you got instead. Do not post your API token or any other secret.

### Disclaimer

This is an **unofficial** Actor. It is not affiliated with, endorsed by, or
sponsored by Flashscore or any league, club or competition named here. All
trademarks belong to their respective owners. The Actor reads publicly available
pages and returns the data they already show to any visitor.

# Changelog

This Actor's version history is a separate document: https://apify.com/scapypuppy/ice-hockey-scraper-nhl/changelog.md

# Actor input Schema

## `dateRange` (type: `string`):

The source keeps results 7 days back and fixtures 7 days ahead. Ignored when From/To date, league URLs, teams or match IDs are set.

## `tours` (type: `array`):

Pick from the list or type any name as Flashscore writes it: 'COUNTRY: League' pins one league, a bare name matches it in every country. Leave empty for all.

## `matchStatuses` (type: `array`):

Leave empty for all. Live now alone reads the live feed first and returns only matches in play; add Live state for the score in play.

## `maxMatches` (type: `integer`):

Most matches per run (monitor mode: matches checked). Top competitions come first among the days read; without filters, reading stops once there are enough.

## `teamFilter` (type: `array`):

Keep only matches of these teams or players from the selected days: names as whole words, or the 8-character IDs from Flashscore links. No extra requests. For a team's history beyond the 15-day window use Teams or players below.

## `teamSide` (type: `string`):

With the team filter above: keep their home matches, away matches or both.

## `startsWithinHours` (type: `number`):

Keep only matches that start from now until this many hours ahead, for example 6. Days that cannot hold such matches are not read.

## `includeStats` (type: `boolean`):

Shots on goal, saves and save percentage, power play and penalty kill, faceoffs, hits, blocked shots, giveaways and takeaways — up to 18 metrics, per match and per period. Every row also carries a stable key and the values as numbers.

## `includeSummary` (type: `boolean`):

Venue, attendance, referees, score by period and the event timeline: goals with both assists, penalties with the reason, power-play and shoot-out attempts.

## `includeLineups` (type: `boolean`):

Rosters with shirt numbers, positions and per-player incidents where the source publishes them, plus missing players (injured, suspended or rested, with the reason) for NHL games. Not every competition has lineups.

## `includePlayerStats` (type: `boolean`):

A line for every skater: goals, assists, points and penalty minutes, with player ids and profile links. Billed per player line.

## `includeH2H` (type: `boolean`):

Past matches of both sides and their direct meetings, plus a form summary per side with home and away records. Billed per row, at most 30 rows a match: the default depth usually reaches 30; the rest is free.

## `maxH2HRecords` (type: `integer`):

How many past matches to keep for each side; 0 keeps everything the source publishes, up to 50 per side. Billed per row, at most 30 a match. The row of the match itself is free and not counted.

## `maxH2HMeetings` (type: `integer`):

How many direct meetings to keep; 0 (the default) keeps all, up to about 75 per pair, which clubs of one league reach. Billed per row, at most 30 a match with the past matches.

## `includeLive` (type: `boolean`):

For matches in play: the running score, the match clock as Flashscore shows it (the time inside the period) and the period. One request for the whole run, not one per match. Finished matches carry live: null and are not billed for it.

## `includePointByPoint` (type: `boolean`):

Flashscore publishes no point-by-point for ice hockey, so this switch returns nothing here and is never billed.

## `resolveRounds` (type: `boolean`):

Adds the round of each match (Final, Semi-finals, Round 6), read from the tournament page and its season feed: two or three requests per tournament in your run, not per match. Matches in play and competitions without rounds, such as the NHL regular season, stay empty.

## `includeNews` (type: `boolean`):

News articles linked to the match: title, source outlet, image and publication time. Published for notable matches; smaller fixtures often have none.

## `includeHighlights` (type: `boolean`):

Links to official video highlights (YouTube and other providers) with preview images. Appear after the match has been played.

## `includeBroadcasts` (type: `boolean`):

Where to watch: the TV channels and streaming services the source lists for the match, by country, plus bookmaker live streams. Billed only when at least one TV channel is listed.

## `includeOdds` (type: `boolean`):

Match-winner prices of every bookmaker in the chosen countries, opening and current, with the average and best price, margin, no-vig probabilities, an arbitrage check and line movement. Matches in play keep their last pre-match prices. One request per match and country.

## `oddsCountries` (type: `array`):

Whose bookmakers to show: licences are national. This Actor starts with the US (New Jersey), about 5 books; Great Britain has about 20. Several countries merge into one record; one request per country per match.

## `onlyWithOdds` (type: `boolean`):

Skip matches with no bookmaker prices: they are neither returned nor billed, and the run checks up to 4 times as many candidates as Max matches to fill the rest. Needs Match-winner odds.

## `oddsFormat` (type: `string`):

American adds prices such as +150 and -110 next to the decimal match-winner prices (homeAmerican and so on), in-play prices included; the price list stays decimal.

## `includeOddsMarkets` (type: `boolean`):

Every Over/Under total and Asian handicap line, plus Both Teams To Score where priced, with the average price and the number of bookmakers behind each line. No extra requests; billed only when the match has such a line.

## `includeLiveOdds` (type: `boolean`):

In-play match-winner prices of every bookmaker taking live bets, up to 10, with each one's first in-play price and whether the price is now above or below it, next to the closing pre-match ones. One request per country plus one per live bookmaker for each match in play; no separate charge.

## `includeOddsOffers` (type: `boolean`):

A flat list of every price of every bookmaker: all markets and parts of the match, with the line, opening and current price and movement. NHL games in October 2026 carried 1 802 to 1 924 rows. Narrow it with the two filters below. No extra requests.

## `oddsOfferMarkets` (type: `array`):

Keep only these markets in the price list; leave empty for all.

## `oddsOfferScopes` (type: `array`):

Keep only these parts of the match in the price list; leave empty for all.

## `leagueUrls` (type: `array`):

Flashscore league pages, such as https://www.flashscore.com/hockey/usa/nhl/ for the current season or https://www.flashscore.com/hockey/usa/nhl-2005-2006/ for a past one. From date and To date still apply.

## `teams` (type: `array`):

Team or player names, or Flashscore team or player links or 8-character IDs, which skip the name search. Returns the full known schedule and recent results of each. From date and To date still apply.

## `teamResultsPages` (type: `integer`):

Used with Teams or players. One page is about 40 past matches. 50 pages of one NHL club reached back to January 2006.

## `matchIds` (type: `array`):

Exact matches on any date: 8-character Flashscore IDs like 'tW4rOkzg' or match links. Overrides Days, league URLs, teams and all filters; Max matches and dates still apply. Unknown IDs are never billed.

## `dateFrom` (type: `string`):

First day to include. On its own it replaces Days, within the source window of seven days back and seven ahead. With league URLs, teams or match IDs it keeps only their matches from this date, so a month of a season costs one month. Leave empty for no lower limit.

## `dateTo` (type: `string`):

Last day to include, inclusive. Works like From date. Leave empty for no upper limit.

## `dayOffsets` (type: `array`):

Read only when Days is Custom. Offsets from today: -1 yesterday, 0 today, 2 the day after tomorrow. Values outside -7 to +7 are dropped.

## `timezone` (type: `string`):

Where one day ends for Days and dates: a time zone name, daylight saving applied, or an offset like +05:30. Empty means America/New_York, for the US game day.

## `timezoneOffset` (type: `integer`):

Older field, use Time zone instead. Whole hours from UTC without daylight saving; 0 or empty means this Actor's default. Ignored when Time zone is set.

## `monitorMode` (type: `boolean`):

Output only matches that are new or changed since the previous run with the same monitor name: new fixtures, kickoff changes, score and status updates, line moves, final results. The first run outputs everything. Records carry changeType and monitorChangedFields.

## `monitorName` (type: `string`):

Name of the monitor's memory, a key-value store in your account; empty means default. Use a different name per saved task or schedule so watchlists do not mix, and never run two monitors with one name at once: turn on the schedule's option that skips a run while the previous one is going.

## `monitorKey` (type: `string`):

Older name of Monitor name, kept hidden so saved tasks keep their monitor memory; Monitor name wins when both are set.

## `omitEmptyFields` (type: `boolean`):

Leave out null values, empty lists and empty objects at every level, so each record carries only the data it has. Off by default: records of runs with the same options then have the same fields, which suits CSV and spreadsheets.

## `outputDatasetName` (type: `string`):

Name of a dataset this Actor creates on its first run and fills with the same records as the run's dataset: one history across scheduled runs. Named datasets are kept until you delete them and their storage is billed by Apify. Letters, digits and hyphens.

## `proxyConfiguration` (type: `object`):

Datacenter proxies are enough; residential proxies are not required.

## Actor input object example

```json
{
  "dateRange": "whole-window",
  "matchStatuses": [],
  "maxMatches": 20,
  "teamSide": "any",
  "includeStats": true,
  "includeSummary": true,
  "includeLineups": false,
  "includePlayerStats": false,
  "includeH2H": false,
  "maxH2HRecords": 10,
  "maxH2HMeetings": 0,
  "includeLive": false,
  "includePointByPoint": false,
  "resolveRounds": false,
  "includeNews": false,
  "includeHighlights": false,
  "includeBroadcasts": false,
  "includeOdds": false,
  "oddsCountries": [
    "US"
  ],
  "onlyWithOdds": false,
  "oddsFormat": "decimal",
  "includeOddsMarkets": false,
  "includeLiveOdds": false,
  "includeOddsOffers": false,
  "oddsOfferMarkets": [],
  "oddsOfferScopes": [],
  "leagueUrls": [],
  "teams": [],
  "teamResultsPages": 2,
  "matchIds": [],
  "dayOffsets": [
    -1,
    0,
    1
  ],
  "monitorMode": false,
  "omitEmptyFields": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

One row per match: ID, date and time in the run's time zone (Time known is false for archive records the source keeps without a start time), competition, teams, numeric score, score text, status and stage, winner, link and when the record was read. Round needs Match round; venue needs Match summary; Parts not loaded names what the source failed to send for that record.

## `odds` (type: `string`):

Match-winner odds per match: average, opening and best price with its bookmaker, margin, no-vig probabilities, best-price overround, arbitrage flag, home-side market move and when the prices were read.

## `live` (type: `string`):

Matches in play: period, clock, live score, points in the current game and server where the sport has them, and when the state was read.

## `statistics` (type: `string`):

One row per statistic per part of the match: part (Match, 1st Half, Set 1...), group, name, stable key, both values as numbers and as the source writes them.

## `events` (type: `string`):

One row per event of the timeline: period, minute as shown and as a number, side, type, player, assist, player coming on, reason and the source's note.

## `lineupsHome` (type: `string`):

One row per player and coach of the home side: role, shirt number, position, rating and substitutions.

## `lineupsAway` (type: `string`):

One row per player and coach of the away side: role, shirt number, position, rating and substitutions.

## `players` (type: `string`):

One row per player line of the box score with the main numbers as columns; every column is in the record's stats and rawStats.

## `bookmakers` (type: `string`):

One row per bookmaker: country, current and opening match-winner prices and whether it still takes bets.

## `prices` (type: `string`):

One row per price of every bookmaker: market, part of the match, selection, line from the home side, current and opening price, move since opening and whether it is still offered.

## `totals` (type: `string`):

One row per over/under line: part of the match, line, average over and under price and how many bookmakers price it.

## `h2h` (type: `string`):

One row per direct meeting of the two sides before this match: this match's ID and sides, then the meeting's date, competition, sides, score, winner and how it ended; the meeting columns have their own names, so a match without head-to-head gives one row with them empty. The match itself is flagged.

## `changes` (type: `string`):

What the monitor emitted and why: new or changed, the groups that changed, the score and status at that moment, and parts the source failed to send. Empty outside monitor mode (monitorMode).

## `runSummary` (type: `string`):

Keys of the run's key-value store that hold RUN_SUMMARY: how the input was understood, what was output, warnings, why the run is empty, stopped early or about to time out, source failures and the IDs of matches not output. A run with nothing to explain writes none, and this list is empty. A cut by maxMatches alone does not write it: the run status says how many matches were cut.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "dateRange": "whole-window",
    "maxMatches": 20,
    "leagueUrls": [],
    "teams": [],
    "matchIds": [],
    "dayOffsets": [
        -1,
        0,
        1
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scapypuppy/ice-hockey-scraper-nhl").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "dateRange": "whole-window",
    "maxMatches": 20,
    "leagueUrls": [],
    "teams": [],
    "matchIds": [],
    "dayOffsets": [
        -1,
        0,
        1,
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("scapypuppy/ice-hockey-scraper-nhl").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "dateRange": "whole-window",
  "maxMatches": 20,
  "leagueUrls": [],
  "teams": [],
  "matchIds": [],
  "dayOffsets": [
    -1,
    0,
    1
  ]
}' |
apify call scapypuppy/ice-hockey-scraper-nhl --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scapypuppy/ice-hockey-scraper-nhl"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/N2aTaaAtm0QvH8yTf/builds/4dPf9J7eaFkaRTChi/openapi.json
