# Tennis Point-by-Point Scraper - Every Point, Stats, ATP & WTA (`neverempty/tennis-point-by-point-scraper`) Actor

For tennis modellers and bettors: every point of each finished ATP, WTA and Challenger match (server, score, break, set and match points) plus match and set stats, one row per match. 3 of 3 matches matched Flashscore's page point for point. Pick days, players or events; monitor new ones.

- **URL**: https://apify.com/neverempty/tennis-point-by-point-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Sports, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $14.60 / 1,000 match returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Tennis Point-by-Point Scraper - Every Point, Stats, ATP & WTA

For tennis modellers, bettors and analysts: every point of each finished ATP, WTA and Challenger match, grouped by set and game, with the server, the score before and after each point, who won it and whether it was a break point, set point or match point, plus Flashscore's match and per-set statistics table (aces, double faults, serve and return points won, break points, games won). In a check on 2026-09-21, 3 of 3 matches (8 sets, 2 tiebreaks, 7 statistics tables) matched Flashscore's own point-by-point and statistics pages point for point. One run returns many matches: pick days, players, tournaments or match IDs, and turn on monitoring so a schedule receives each newly finished match once.

Export as JSON, CSV or Excel.

Unofficial. Public data only.

### What you get

One row per finished match (finished, retired or awarded), for any day from 7 days back to today:

- **Match**: players as Flashscore shows them (`De Minaur A.`), name slug, Flashscore player id and country; doubles pairs with each partner; tournament, full header (`WTA - SINGLES: Seoul (South Korea), hard`), tour, singles or doubles, surface, round, start time, sets won, games per set with tiebreak points, `scoreText` such as `6-2 6(7)-7(9) 3-6`, winner.
- **Every point** (`pointByPoint`): sets → games → points. Each game has the server, who won it and whether the server was broken. Each point has `scoreBefore` (`40:30`), `scoreAfter`, `wonBy` (1 or 2), `breakPoint` with `breakPointFor`, `setPoint` and `matchPoint` with `setPointFor`. Tiebreaks and 10-point match tiebreaks list every point with its server.
- **Totals**: `pointCount`, `player1PointsWon`, `player2PointsWon`, `deducedPoints`, and `pointsAgreeWithStats` (whether those totals equal Flashscore's own "Total Points Won").
- **Statistics** (`stats`): one entry per metric for the match and for each set, with Flashscore's text (`60% (42/70)`) split into `percent`, `number` and `total` for both players.

On Sunday 2026-09-20 Flashscore listed 126 finished ATP, WTA and Challenger matches (30 ATP including Davis Cup rubbers, 8 WTA, 88 Challenger).

### Which matches have point-by-point

Flashscore publishes point-by-point for ATP, WTA and Challenger matches, singles and doubles. Of the 142 matches played to a finish on 2026-09-20 (team ties as a whole not counted): all 8 WTA and all 88 Challenger matches had it, ATP 27 of 30 (the 3 without were Davis Cup rubbers), ITF 9 of 16. Team ties as a whole (Davis Cup, Billie Jean King Cup) and cancelled matches have none. A finished match without point-by-point is **not returned and not charged**; a free row lists its ID. Walkovers have no points and are not returned.

A retired match lists every completed game; the points of the game in progress when the player retired are not on Flashscore's point-by-point.

### How the numbers were checked

- **Against Flashscore's page**: on 2026-09-21 the point-by-point and statistics tabs of 3 matches (an ATP Davis Cup rubber, a WTA match with a tiebreak and match points, an ITF match with a final-set tiebreak) were opened in a browser, and the feeds were read at the same moment. All 8 sets matched score by score, including every BP, SP and MP label and both tiebreaks; all 7 statistics tables (match and each set) matched value by value.
- **Against the final score**: for all 132 of those matches with point-by-point, the games in the point-by-point add up to the final set scores (including tiebreaks and match tiebreaks).
- **Against Flashscore's own totals**: for the 117 of them whose statistics have "Total Points Won", the points counted from the point-by-point equal it in 102 matches; in 15 they differ by 1 to 4 points for a player (Flashscore's statistics table and its point-by-point do not always agree; a retirement also leaves out the unfinished game). Each row says which in `pointsAgreeWithStats` (null when the statistics have no total).
- **Skipped scores**: in 5 of the 132 matches Flashscore's point list skips one score inside a game (`0:15` followed by `30:15`). Tennis scoring leaves only one possibility (the same player won both points), so the missing point is added with `deduced: true`; its break, set and match point flags are null because Flashscore did not list that score. The row's `deducedPoints` counts such games. Any other kind of gap would make the match unreadable, and it would not be returned.
- **Freshness**: Flashscore's feeds are served from a cache that can be minutes old. A match's point-by-point is returned only when its last games equal the final set scores; if the cached copy is still missing the end of the match, the match is not returned or charged (in monitoring mode it is checked again on later runs, at most 3 times).
- Percentages are Flashscore's feed values. Flashscore's page recomputes them from the counts and can round a half differently (the feed says 13% for 1/8, the page 12%); `number` and `total` are exact.

### Input

| Field | What it does |
|---|---|
| `days` | Days to read: `today`, `yesterday`, a number from `-7` to `0`, or `YYYY-MM-DD`. Empty = yesterday (monitoring: yesterday and today; with `matchIds`: the last 7 days and today). All matches of all days asked for are read in one run. |
| `matchIds` | Only these matches: Flashscore match IDs (`ILuYNkiU`) or flashscore.com match links. They must be in Flashscore's daily list of the days read (the last 7 days); IDs not found come back in a free row. |
| `players` | Keep a match if one of these appears in a player's name, slug or doubles partner (partial, case-insensitive). |
| `tournaments` | Keep a match if the tournament header contains one of these (partial, case-insensitive). |
| `tours` | `atp`, `wta`, `challenger-men`, `challenger-women`, `itf-men`, `itf-women`, `other`. Empty = all. |
| `matchTypes` | `singles`, `doubles`. Empty = both. |
| `utcOffsetHours` | Your time zone as hours from UTC (0 = UTC). Decides where a day starts, as on Flashscore in that time zone. `startTime` is always UTC. |
| `maxMatches` | Maximum match rows to return (default 25). Not used in monitoring mode. |
| `includeRound` | On (default) = one small extra request per returned match to read the round from its page (only the first 24 KB are requested). Off = faster, `round` is null. |
| `monitoringMode` | Return only matches that finished since the last run for this input. |
| `resetMonitoringState` | Forget the remembered matches for this input, so every finished match counts as new. |
| `useProxy` | On (default) = if Flashscore answers 401, 403, 429 or 503, retry through an Apify proxy. The feeds answered from a plain Apify datacentre address when this Actor was built. |

Example: yesterday's and today's finished WTA and ATP singles.

```json
{
    "days": ["yesterday", "today"],
    "tours": ["atp", "wta"],
    "matchTypes": ["singles"],
    "maxMatches": 100
}
```

Example: every match of one player in the last week.

```json
{
    "days": ["-7", "-6", "-5", "-4", "-3", "-2", "-1", "0"],
    "players": ["sinner"]
}
```

### Output

A finished match (trimmed: the real row lists every game and point, and 55 statistics entries for this two-set match):

```json
{
    "source": "flashscore.com",
    "status": "ok",
    "matchId": "8814Zui2",
    "matchUrl": "https://www.flashscore.com/match/8814Zui2/",
    "date": "2026-09-20",
    "startTime": "2026-09-20T05:05:00.000Z",
    "tournament": "Davis Cup - World Group I (World)",
    "tournamentHeader": "ATP - SINGLES: Davis Cup - World Group I (World)",
    "tour": "ATP",
    "matchType": "singles",
    "round": null,
    "roundStatus": "not-shown",
    "matchStatus": "finished",
    "player1Name": "De Minaur A.",
    "player1Country": "Australia",
    "player2Name": "Majchrzak K.",
    "player2Country": "Poland",
    "winner": 1,
    "winnerName": "De Minaur A.",
    "sets": [
        { "set": 1, "player1Games": 6, "player2Games": 3, "player1Tiebreak": null, "player2Tiebreak": null },
        { "set": 2, "player1Games": 6, "player2Games": 3, "player1Tiebreak": null, "player2Tiebreak": null }
    ],
    "scoreText": "6-3 6-3",
    "pointCount": 106,
    "player1PointsWon": 64,
    "player2PointsWon": 42,
    "deducedPoints": 0,
    "pointsAgreeWithStats": true,
    "statistics": "full",
    "pointByPoint": [
        {
            "set": 1,
            "games": [
                {
                    "game": 1, "player1Games": 0, "player2Games": 1,
                    "server": 2, "serverName": "Majchrzak K.", "winner": 2, "winnerName": "Majchrzak K.",
                    "serviceBroken": false, "isTiebreak": false,
                    "points": [
                        { "point": 1, "scoreBefore": "0:0", "scoreAfter": "0:15", "wonBy": 2, "breakPoint": false, "breakPointFor": null, "setPoint": false, "matchPoint": false, "setPointFor": null, "deduced": false },
                        { "point": 5, "scoreBefore": "40:15", "scoreAfter": "40:30", "wonBy": 2, "breakPoint": true, "breakPointFor": 1, "setPoint": false, "matchPoint": false, "setPointFor": null, "deduced": false },
                        { "point": 8, "scoreBefore": "40:A", "scoreAfter": "game", "wonBy": 2, "breakPoint": false, "breakPointFor": null, "setPoint": false, "matchPoint": false, "setPointFor": null, "deduced": false }
                    ]
                }
            ],
            "tiebreak": null,
            "matchTiebreak": null
        }
    ],
    "stats": [
        { "scope": "match", "set": null, "category": "Service", "metric": "Aces", "player1Value": "9", "player2Value": "0", "player1Percent": null, "player2Percent": null, "player1Number": 9, "player2Number": 0, "player1Total": null, "player2Total": null },
        { "scope": "match", "set": null, "category": "Points", "metric": "Total Points Won", "player1Value": "60% (64/106)", "player2Value": "40% (42/106)", "player1Percent": 60, "player2Percent": 40, "player1Number": 64, "player2Number": 42, "player1Total": 106, "player2Total": 106 }
    ],
    "scrapedAt": "2026-09-21T15:08:56.250Z"
}
```

Notes on the columns:

- `playerOrder` is `as-listed`: player 1 is the upper name on Flashscore, not necessarily the winner. `wonBy`, `server`, `winner`, `breakPointFor` and `setPointFor` are 1 or 2 in that order.
- A point's flags describe the situation it was played in: `scoreBefore` `40:15` with `breakPoint: true` is a break point for `breakPointFor`. `matchPoint: true` always comes with `setPoint: true`. `setPointFor` is null at a no-ad deciding point (`40:40` in doubles), where Flashscore does not say whose it is.
- The last point of a game has `scoreAfter: "game"`. In a tiebreak `scoreBefore` / `scoreAfter` are tiebreak points (`6:5`), and `serveLost` says the server lost the point.
- The set-deciding tiebreak game appears in `games` with `isTiebreak: true` and the tiebreak points in the set's `tiebreak`. A 10-point match tiebreak played instead of a final set is in `matchTiebreak`.
- `statistics`: `full` (serve, return, points and games tables), `partial` (only some metrics, as for many ITF matches) or `none` (Flashscore answered with no statistics). A match whose statistics request is refused is not returned.
- `roundStatus`: `ok`, `not-shown` (the match page shows no round, as for Davis Cup rubbers), `unreadable` or `not-requested`.
- Odds are not included.

#### Rows that are not charged

When something is not returned, a row says why. These rows have no `matchId` and cost nothing:

| `status` | Meaning |
|---|---|
| `no-point-by-point` | These finished matches have no point-by-point on Flashscore; their IDs are listed. |
| `unreadable` | A match's point-by-point does not yet reach its final score (a match that ended minutes ago), or Flashscore refused or garbled its point-by-point or statistics. Nothing is guessed and no row is sold without its statistics request answered. |
| `no-filter-match` | Matches were read, none fits your input, or none of those that fit has finished. |
| `match-not-found` | Match IDs not in Flashscore's daily lists of the days read. |
| `no-new-finished-matches` | Monitoring: no match finished since the last run. |
| `not-returned` | More finished matches fit than `maxMatches`; the row says how many were left. |
| `budget-reached` | The run hit its maximum total charge; the row says how many matches were not returned. |
| `no-results` | Flashscore lists no matches for the day(s) asked for. |
| `day-out-of-range` | Flashscore did not return the day. |
| `blocked` | Flashscore refused the request even after retrying. |
| `invalid-input` | The input could not be used; nothing was requested. |

### Monitoring

Run the Actor on a schedule with `monitoringMode` on (for example every hour, with the days left empty so it reads yesterday and today). The first run returns every finished match that fits your input; later runs return only matches that finished since.

- Every finished match that fits your input and was not returned before is a **check** once Flashscore answers for its point-by-point; its row follows if it has point-by-point and statistics. Matches already returned, matches not finished yet and requests Flashscore refuses are not charged.
- A finished match without point-by-point, or whose point-by-point does not reach the final score, is checked on at most 3 runs, then left alone.
- A match is remembered only after its row was delivered and charged. If a run hits its maximum total charge, the matches it could not return are not remembered, so a later run with enough room checks them.
- Matches are remembered per input (`tours`, `matchTypes`, `players`, `tournaments`, `matchIds`) for 10 days after they start. `maxMatches` is not used in monitoring mode.
- Do not put the same input in two schedules that can run at the same time: the remembered matches live in a key-value store without atomic updates, so a match could be returned twice.

### Pricing

Pay per event:

- **$20.00 per 1,000 match rows** on the Apify Free plan (lower on paid Apify plans), charged only for rows with `status: "ok"`. One row is one whole match with every point and the statistics table.
- **$0.30 per 1,000 match checks** in monitoring mode.

Example: an hourly schedule over ATP and WTA sees roughly 40 newly finished matches a day: 40 rows ($0.80) and 40 checks ($0.012).

### Notes

- Unofficial. Not affiliated with Flashscore or Livesport. Data is what Flashscore shows publicly and can change or be corrected by Flashscore.
- Only Flashscore's tennis feeds and match pages that its robots.txt allows are read; draws, standings and news are not.
- Requests are spaced out: a 0.4 s pause before each request, and at most 3 matches read at once.

# Actor input Schema

## `days` (type: `array`):

Which days to read finished matches from: today, yesterday, a number of days from today (-7 to 0), or a date as YYYY-MM-DD. Flashscore's daily list covers the last 7 days; other days are refused before anything is requested. Empty = yesterday (in monitoring mode: yesterday and today; with match IDs: all of the last 7 days and today). All matches of every day asked for are read in one run.

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

Only these matches. A Flashscore match ID (8 letters and digits, e.g. ILuYNkiU) or a flashscore.com match link (the ID is the mid= part or the part after /match/). The match must be in Flashscore's daily list of the days read (the last 7 days). Empty = every finished match of the days read that fits the filters below.

## `players` (type: `array`):

Keep a match only if one of these appears in either side's name or name slug (case-insensitive, partial). Flashscore writes names as 'Sinner J.' and slugs as 'sinner-jannik', so sinner or jannik both work. Doubles partners are matched too.

## `tournaments` (type: `array`):

Keep a match only if the tournament header contains one of these (case-insensitive, partial). Example: us open, shanghai, qualification.

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

Keep only these tours. Empty = all. Point-by-point is published for ATP, WTA and Challenger matches; for ITF matches only for some (9 of 16 finished on 2026-09-20). Individual Davis Cup rubbers are listed under ATP.

## `matchTypes` (type: `array`):

Keep only these. Empty = both. Team ties as a whole (Davis Cup, Billie Jean King Cup) carry no point-by-point and are never returned; their individual rubbers are.

## `utcOffsetHours` (type: `integer`):

Where a day starts and ends, as on Flashscore when your browser is in that time zone. 0 = UTC. Example: -5 for New York in winter, 1 for Paris in winter, 9 for Tokyo. startTime is always in UTC.

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

How many match rows to return. One row = one finished match with every point and the statistics table. You are charged only for the rows you receive. On Sunday 2026-09-20 Flashscore listed 126 finished ATP, WTA and Challenger matches. Not used in monitoring mode, which returns every newly finished match that fits your input.

## `includeRound` (type: `boolean`):

The round is not in Flashscore's feeds. On = one extra small request per returned match to read it from the match page (only the first 24 KB are requested). Off = faster, round is null.

## `monitoringMode` (type: `boolean`):

Off = return the finished matches that fit your input. On = remember which matches were already returned and, on later runs, return only matches that finished since. The first run returns every finished match that fits. Each finished match not returned before is a check with a small fee once Flashscore answers for it (then its row, if it has point-by-point); matches already returned, matches not finished yet and refused requests are not charged. Remembered per input, for 10 days.

## `resetMonitoringState` (type: `boolean`):

Clear the remembered matches for this input before this run, so every finished match counts as new again.

## `useProxy` (type: `boolean`):

The feeds answered from a plain Apify datacentre address when this Actor was built (2026-09-21). On = if Flashscore answers 401, 403, 429 or 503, the request is retried through a proxy. Off = retried directly, then reported in a free row.

## Actor input object example

```json
{
  "days": [
    "yesterday"
  ],
  "tours": [],
  "matchTypes": [],
  "utcOffsetHours": 0,
  "maxMatches": 25,
  "includeRound": true,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "useProxy": true
}
```

# Actor output Schema

## `results` (type: `string`):

One row per finished tennis match on Flashscore: players with slug, id and country, tournament, tour, round, surface, start time, set scores with tiebreaks, winner; every point grouped by set and game with the server, the score before and after, who won it and whether it was a break, set or match point; tiebreak points; and the match and per-set statistics table. Matches without point-by-point, filters that match nothing, refused requests and runs that hit their maximum charge come back as free rows that say why.

# 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 = {
    "days": [
        "yesterday"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/tennis-point-by-point-scraper").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 = { "days": ["yesterday"] }

# Run the Actor and wait for it to finish
run = client.actor("neverempty/tennis-point-by-point-scraper").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 '{
  "days": [
    "yesterday"
  ]
}' |
apify call neverempty/tennis-point-by-point-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/tennis-point-by-point-scraper"
        }
    }
}
```

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/ORmlkNJco3p3oSuTM/builds/ZH3bWxj6hJBdTqZM3/openapi.json
