# Tennis Point-by-Point Scraper — Rally & Match Stats (`sian.agency/tennis-point-by-point-scraper`) Actor

Point-by-point tennis data no other scraper sells — every serve, rally, and break point of a match, in play order, plus the complete match- and set-level statistics table. Find players by name, then export full match records as JSON, CSV, or Excel. No API key.

- **URL**: https://apify.com/sian.agency/tennis-point-by-point-scraper.md
- **Developed by:** [SIÁN OÜ](https://apify.com/sian.agency) (community)
- **Categories:** Sports, Automation, Developer tools
- **Stats:** 12 total users, 8 monthly users, 98.2% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $18.75 / 1,000 match records

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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

In JavaScript/TypeScript projects, use official [JavaScript/TypeScript client](https://docs.apify.com/api/client/js/docs.md):

```bash
npm install apify-client
```

In Python projects, use official [Python client library](https://docs.apify.com/api/client/python/docs.md):

```bash
pip install apify-client
```

In shell scripts, use [Apify CLI](https://docs.apify.com/cli/docs.md):

````bash
# MacOS / Linux
curl -fsSL https://apify.com/install-cli.sh | bash
# Windows
irm https://apify.com/install-cli.ps1 | iex
```bash

In AI frameworks, you might use the [Apify MCP server](https://docs.apify.com/integrations/mcp.md).

If your project is in a different language, use the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).


# README

## Tennis Point-by-Point Scraper — Rally & Match Stats 🎾

[![SIÁN Agency Store](https://img.shields.io/badge/Store-SI%C3%81N%20Agency-1AE392)](https://apify.com/sian.agency?fpr=sian) [![Sports Data Scraper](https://img.shields.io/badge/Store-Sports%20Data%20Scraper-1AE392)](https://apify.com/sian.agency/sports-data-scraper?fpr=sian) [![Instagram AI Transcript](https://img.shields.io/badge/Store-Instagram%20AI%20Transcript-E4405F)](https://apify.com/sian.agency/instagram-ai-transcript-extractor?fpr=sian) [![Xiaohongshu Scraper](https://img.shields.io/badge/Store-Xiaohongshu%20Scraper-FF2442)](https://apify.com/sian.agency/xiaohongshu-rednote-scraper?fpr=sian)

#### 🎾 The only scraper that returns **point by point tennis data** — every serve, rally, and break point, plus the full tennis match statistics table
##### Built for analysts, traders, scouts, and developers who need real tennis data, not headlines

---

### 📋 Overview

**Serve, rally, break, repeat.** Every other tennis scraper hands you a scoreline. This one hands you the match: the player directory, match metadata, every point of every game in play order, and the complete match- and set-level statistics table — as clean, structured rows you can analyze in seconds.

**Why analysts and developers choose us:**
- 🥇 **Rally-level data nobody else sells**: no other tennis scraper on the Store returns point-by-point sequences at any price
- ⚡ **Two clicks to data**: find a player, then pull their matches — no API key, no login
- 🎯 **Point-by-point depth**: every serve, rally, and break point, in order, ready for modeling
- 🛡️ **Controlled bulk retrieval**: explicit match limits, per-match failure isolation, and resumable two-stage jobs
- 💎 **Full statistics table**: serve, return, break points, plus performance and shot-quality ratings, at match and set level
- 🎾 **Singles-only mode**: filter out doubles before parsing — and never pay for the rows you filtered
- 🌐 **Proxies included**: nothing to configure, nothing extra to pay — we route every request through our own residential network
- ✨ **Export anywhere**: JSON, CSV, or Excel, or straight from the API

---

### ✨ Features

- 🔎 **Player Directory Search**: find any player by name and get a stable ID
- 🎾 **Point-by-Point Data**: sets → games → points, in play order
- 📊 **Match & Set Statistics**: serve/return/break-point rows plus shot-quality ratings
- 🏟️ **Rich Match Metadata**: tournament, location, date, and both players
- 👥 **Multi-Player Runs**: scrape many players at once — shared matches are fetched only once
- 🧭 **Two-Stage Actor Operations**: discover unique match IDs first, then scrape bounded detail batches
- 🔌 **Stable Retrieval Contract**: production clients can plan, deduplicate, and resume outside the Actor
- 🎛️ **Toggle the Payload**: include or skip points/statistics per run
- 📁 **Clean Structured Output**: one tidy dataset per run, ready for analysis
- 🔑 **No API Key Required**: just add input and run

---

### 🎬 Quick Start

Scrape a player's matches — points, rallies and statistics — in one call.

```bash
curl -X POST "https://api.apify.com/v2/acts/sian.agency~tennis-point-by-point-scraper/runs?token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"operation": "scrape-matches", "playerIds": ["feca401d-e9c2-4638-93b0-878fdb9aea74"], "maxMatchesPerPlayer": 5}'
````

Don't have a player ID yet? Look one up first:

```bash
curl -X POST "https://api.apify.com/v2/acts/sian.agency~tennis-point-by-point-scraper/runs?token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"operation": "discover-players", "search": "Dinev"}'
```

***

### 🚀 Getting Started (3 Simple Steps)

#### Step 1: Hit Start

`scrape-matches` is the default operation and a player is already prefilled, so a first run returns real matches with full point-by-point data for a few cents. No setup at all.

#### Step 2: Point it at your own players

Switch to `discover-players`, search a name, and copy the `playerId` from the results into `playerIds`. Then switch back to `scrape-matches`.

> ⚠️ Leaving `search` empty returns the **entire directory — about 2,600 players, billed per row.** It is capped at `maxPlayers` (default 100), but keep a name in there unless you deliberately want a big slice.

#### Step 3: Export your data

Download the dataset as JSON, CSV, or Excel — or pull it straight from the Apify API.

**That's it! In minutes, you'll have:**

- A searchable directory of players with stable IDs
- Full match records with point-by-point data
- Match and set statistics ready for analysis

***

### 📥 Input Configuration

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| operation | string | Yes | `discover-players`, `discover-matches`, `scrape-matches`, or `scrape-match-ids` |
| search | string | No | Filter the player directory by name or ID (discover only) |
| playerIds | array | No | Player IDs for `discover-matches` or `scrape-matches` |
| matchRequests | array | No | Up to 250 `{matchId, requestedPlayerIds}` objects for `scrape-match-ids` |
| maxMatchesPerPlayer | integer | No | Matches per player; default `5`, `0` = all |
| maxTotalMatches | integer | No | Hard ceiling on billed matches per run after deduplication; default `20` |
| includePoints | boolean | No | Include nested point-by-point data (default true) |
| includeStats | boolean | No | Include match and set statistics (default true) |
| matchFormat | string | No | `all` (default) or `singles`; singles mode excludes doubles before point/stat parsing |

**Find players:**

```json
{
  "operation": "discover-players",
  "search": "Dinev"
}
```

**Scrape matches:**

```json
{
  "operation": "scrape-matches",
  "playerIds": ["feca401d-e9c2-4638-93b0-878fdb9aea74"],
  "maxMatchesPerPlayer": 10,
  "maxTotalMatches": 50,
  "matchFormat": "singles",
  "includePoints": true,
  "includeStats": true
}
```

**Build a globally deduplicated match plan:**

```json
{
  "operation": "discover-matches",
  "playerIds": ["feca401d-e9c2-4638-93b0-878fdb9aea74"],
  "maxMatchesPerPlayer": 25,
  "maxTotalMatches": 5000
}
```

**Scrape explicit match IDs:**

```json
{
  "operation": "scrape-match-ids",
  "matchRequests": [
    {
      "matchId": "c292a56a-5fbe-464e-a768-0fc26f483be2",
      "requestedPlayerIds": ["feca401d-e9c2-4638-93b0-878fdb9aea74"]
    }
  ],
  "matchFormat": "singles"
}
```

***

### 📤 Output

Results are saved to the Apify dataset. Runs return **player**, **match-reference**, **match**, or **match-exclusion** records. In singles mode, doubles produce lightweight exclusions after metadata classification and before point/stat parsing. Unknown formats are fully scraped to avoid accidental data loss. Structured per-item failures are stored in the run's `FAILURES` key-value record.

| Field | Type | Description |
|-------|------|-------------|
| recordType | string | `player`, `match-reference`, `match`, or `match-exclusion` |
| playerId / fullName | string | Player ID and name (player records) |
| requestedPlayerIds | array | Requested players that led to a match reference or record |
| matchId / title | string | Match ID and title (match records) |
| matchFormat | string | `singles`, `doubles`, or `unknown` |
| exclusionReason | string | Why a `match-exclusion` record was not fully parsed |
| tournament / location | string | Event name and place |
| completedAt | string | When the match finished |
| pointCount | number | Total points in the match |
| playerIds | array | Both players resolved from point data |
| sets | array | Nested sets → games → points |
| stats | array | Match- and set-level statistics rows |

**Example (match record — real output, trimmed):**

```json
{
  "recordType": "match",
  "matchId": "313b5434-cffc-4938-804c-e6edeec28e8e",
  "title": "Ivanov vs Dinev | 2026#3007#QS008 | Tennis Results",
  "tournament": "LEXUS NOTTINGHAM CHALLENGER 3",
  "matchFormat": "singles",
  "pointCount": 146,
  "playerIds": ["7626aa33-...", "feca401d-..."],
  "sets": [
    { "setNumber": 1, "games": [{ "gameNumber": 1, "points": [
      {
        "pointNumber": 1,
        "setNumber": 1,
        "gameNumber": 1,
        "winner": "teamA",
        "teamServing": "teamA",
        "playerServing": "7626aa33-7f05-436e-a24a-282a666310f8",
        "playerReceiving": "feca401d-e9c2-4638-93b0-878fdb9aea74",
        "rally": [
          { "sequence": 1, "type": "SERVE" },
          { "sequence": 2, "type": "ACE" },
          { "sequence": 3, "type": "POINT_COMPLETE" }
        ],
        "pointBreakdown": {
          "outcome": "winner", "serve": "ACE", "totalShots": "1",
          "winningShotType": "serve", "errorType": null, "situation": "serving"
        },
        "ace": true, "isBreakPoint": false, "isDeuce": false,
        "streak": { "owner": "teamA", "count": 1 },
        "time": "2026-07-06T13:27:53.474Z"
      }
    ] }] }
  ],
  "stats": [
    { "scope": "match", "category": "Points", "metric": "Total Points Won", "teamAValue": "81", "teamBValue": "65" },
    { "scope": "set", "setNumber": "1", "category": "Service", "metric": "Break Points Saved", "teamAValue": "4", "teamBValue": "3" }
  ]
}
```

That single match carried **146 points across 3 sets and 84 statistic rows** — one billed record.

***

### 💼 Use Cases & Examples

#### 1. Tennis Analytics & Modeling

**Data scientists building serve/return and momentum models.**
**Input:** Player IDs of the players you follow. **Output:** Point-by-point sequences + statistics. **Use:** Train win-probability and momentum models on real rally data.

#### 2. Betting & Trading Research

**Traders backtesting tennis markets.**
**Input:** A roster of players. **Output:** Historical match results with detailed in-match stats. **Use:** Validate edges and study live-trading spots.

#### 3. Player Scouting & Coaching

**Coaches and scouts preparing for opponents.**
**Input:** A player's ID. **Output:** Full match history with serve/return breakdowns. **Use:** Build opponent scouting reports and head-to-head views.

#### 4. Sports Media & Content

**Journalists and creators covering tournaments.**
**Input:** Search a player by name. **Output:** Match metadata + stats. **Use:** Fact-check stories and generate match recaps.

#### 5. Data Products & Feeds

**Developers powering apps and dashboards.**
**Input:** Scheduled runs over a player list. **Output:** Clean JSON match records. **Use:** Feed a live tennis stats product without maintaining a scraper.

#### 6. Fantasy & Community Tools

**Fantasy platforms and stat communities.**
**Input:** A directory of players. **Output:** Match and point data. **Use:** Power fantasy scoring and leaderboards.

***

### 🔗 Integration Examples

#### JavaScript/Node.js

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });

const run = await client.actor('sian.agency/tennis-point-by-point-scraper').call({
  operation: 'scrape-matches',
  playerIds: ['feca401d-e9c2-4638-93b0-878fdb9aea74'],
  maxMatchesPerPlayer: 10
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items[0]);
```

#### Python

```python
from apify_client import ApifyClient
client = ApifyClient('YOUR_TOKEN')

run = client.actor('sian.agency/tennis-point-by-point-scraper').call(
    run_input={
        'operation': 'scrape-matches',
        'playerIds': ['feca401d-e9c2-4638-93b0-878fdb9aea74'],
        'maxMatchesPerPlayer': 10,
    }
)

for item in client.dataset(run['defaultDatasetId']).iterate_items():
    print(item)
```

#### cURL

```bash
curl -X POST 'https://api.apify.com/v2/acts/sian.agency~tennis-point-by-point-scraper/runs?token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"operation": "discover-players", "search": "Dinev"}'
```

#### Automation Workflows (N8N / Zapier / Make)

1. **Trigger**: Schedule or webhook
2. **HTTP Request**: Call the actor API
3. **Process**: Handle JSON results
4. **Action**: Save, notify, or transform

***

### 📊 Performance & Pricing

**You pay per record, and only for records you actually receive:**

| Event | What it is |
|---|---|
| **Match record** | One complete match — metadata, every point of every game, and the full match & set statistics table |
| **Player record** | One player-directory entry (ID, name, results link) |

A match record is an entire match, not a scoreline: a typical singles match carries 100–200+ points plus dozens of statistic rows in a single billed row. **Doubles excluded by `matchFormat: "singles"` and plan rows from `discover-matches` are never charged** — you only pay for data you asked for and received.

**Proxies are on us.** There is nothing to configure and nothing extra to pay: every request goes through our own residential proxy network, included in the per-record price. You will not find a proxy field in the input — you never need one.

**Your first run is cheap by design.** The defaults scrape a small sample (5 matches per player, 20 total) so you can inspect the data shape for pennies before scaling up. There is no Free/Paid cap — raise `maxTotalMatches` up to 10,000 whenever you are ready. It is a hard ceiling: a run can never bill more matches than that number. Apify compute and storage are billed by Apify under the account that starts the run.

### 🏗️ Large-Scale Retrieval

For jobs bigger than one run, use the two-stage contract. Call `discover-matches` with your full player roster to get a globally deduplicated match plan — every unique match ID, each fetched only once no matter how many of your players played in it. Then submit those IDs to `scrape-match-ids` in bounded batches of up to 250.

Because the plan is stable and deduplicated, your own client can checkpoint it, resume after an interruption, and re-run only the match IDs it is still missing.

***

### ❓ Frequently Asked Questions

**Q: How do I get a player's matches?**
A: Run `discover-players` first to get the player's ID, then pass that ID to `scrape-matches`.

**Q: How many matches can I scrape?**
A: Defaults are deliberately small — 5 per player, 20 per run — so a first run costs pennies. Raise `maxTotalMatches` up to 10,000 for a planned job, or split larger ones with `discover-matches` → `scrape-match-ids`.

**Q: Am I charged for doubles I filtered out?**
A: No. In `singles` mode, doubles are classified from match metadata and skipped before any point or statistics parsing, and those exclusion rows are not billed.

**Q: Can I get every point of a match?**
A: Yes — with `includePoints` enabled, each match returns its sets, games, and points in order.

**Q: What statistics are included?**
A: Serve, return, and break-point stats plus performance and shot-quality ratings, at both match and set level.

**Q: What output formats are available?**
A: JSON, JSONL, CSV, and Excel — export directly from the Apify dataset.

**Q: Do I need an account or API key for the source?**
A: No. Just add your input and run the Actor.

**Q: Do I need to set up a proxy?**
A: No — and there is no proxy field to fill in. Every request is routed through our own residential proxy network at no extra cost to you.

**Q: Which players and tournaments are covered?**
A: The player directory carries thousands of players, and match coverage is strongest on ATP Challenger and ITF-level events. Not every player in the directory has matches on file — in a 31-player sample, 19 had match listings (up to 20 matches each). Run `discover-players` and then `discover-matches` to see exactly what is available for your roster before committing to a large scrape; `discover-matches` plan rows are not charged.

***

### 🐛 Troubleshooting

**No matches returned for a player**

- Double-check the player ID from `discover-players`
- The player genuinely may have no matches on file — the directory lists more players than have match records. Use `discover-matches` (unbilled) to check a roster before scraping it.

**Run stopped at the total-match limit**

- Increase `maxTotalMatches` deliberately after reviewing the expected number of billed match records

**Empty player search**

- Broaden your `search` term, or leave it empty to return the full directory

***

### ⚖️ Is it legal to scrape data?

Our actors are ethical and do not extract any private user data, such as email addresses, gender, or location. They only extract what the user has chosen to share publicly. We therefore believe that our actors, when used for ethical purposes by Apify users, are safe.

However, you should be aware that your results could contain personal data. Personal data is protected by the **GDPR** in the European Union and by other regulations around the world. You should not scrape personal data unless you have a legitimate reason to do so. If you're unsure whether your reason is legitimate, consult your lawyers.

You can also read Apify's blog post on the [legality of web scraping](https://blog.apify.com/is-web-scraping-legal/).

***

### 🤝 Support

[![Telegram Support](https://img.shields.io/badge/Telegram-Support%20Group-0088cc?logo=telegram)](https://t.me/+vyh1sRE08sAxMGRi)

**Join our active support community**

- For issues or questions, open an issue in the actor's repository
- Check [SIÁN Agency Store](https://apify.com/sian.agency?fpr=sian) for more automation tools
- 📧 <apify@sian-agency.online>

***

**Built by [SIÁN Agency](https://www.sian-agency.online)** | **[More Tools](https://apify.com/sian.agency?fpr=sian)**

# Actor input Schema

## `operation` (type: `string`):

🎯 **Pick one operation per run.** Each run produces one clean dataset matching the chosen mode.

- **🎾 Scrape a player's matches** — the main event. Full matches: every point in play order plus the complete statistics table
- **🔎 Find players** — look up a player by name to get their ID
- **📋 Build a match list** — collect unique match IDs for a roster without scraping them yet (not billed)
- **📋 Scrape specific matches** — scrape match IDs you already have

💡 **Just hit Start.** A player is already filled in below, so a default run returns real matches straight away.

🔎 **Scraping someone else?** Switch to `Find players`, search their name, copy the `playerId` from the results into 🎾 Player IDs, then switch back here.

🧭 **For big jobs:** `Build a match list` first — it deduplicates matches shared between players so you never pay for the same match twice — then feed the IDs to `Scrape specific matches` in batches.

## `playerIds` (type: `array`):

🎾 **Required for `Scrape a player's matches` and `Build a match list`.** Ignored by the other operations.

Paste one or more player IDs from your `Find players` results. Add as many as you like — up to 500 — and a match shared by two of them is fetched, and billed, only once.

💡 Not every player in the directory has matches on file; coverage is strongest on ATP Challenger and ITF events. Run `Build a match list` first to see what exists for your roster without being billed for it.

## `search` (type: `string`):

🔎 **Used by `Find players`.** Ignored by every other operation.

Type part of a player's name — first name, surname, or both. Matching is partial and case-insensitive, so `dinev` and `Dinev` both work.

⚠️ **Leave this empty and you get the whole directory — about 2,600 players, billed per row.** Keep a name here unless you deliberately want the full list.

💡 **Next step:** copy the `playerId` from your results into 🎾 Player IDs, then switch the operation back to `Scrape a player's matches`.

## `maxPlayers` (type: `integer`):

The hard ceiling on player rows returned by `Find players`.

This is your spend guard: every player row is billed, and an empty name field matches the whole directory of roughly 2,600 players. Raise it when you genuinely want a bigger slice — up to 10,000.

## `matchRequests` (type: `array`):

📋 **Required for `Scrape specific matches`.** Ignored by every other operation — most users never touch this field.

Use it only when you already hold match IDs, typically from a `Build a match list` run, and want to scrape them in controlled batches. Up to 250 per run.

Each entry takes a `matchId`, and optionally the players it belongs to:

```json
[{ "matchId": "313b5434-cffc-4938-804c-e6edeec28e8e" }]
```

💡 **Scraping one player?** Ignore this and use `Scrape a player's matches` instead.

## `maxMatchesPerPlayer` (type: `integer`):

Cap how many matches to take from each player's history. Set `0` to take every match on file.

Starts at `5` so your first run is quick and cheap. Raise it once you have seen the data — there is no upper limit beyond the total cap below.

## `maxTotalMatches` (type: `integer`):

The hard ceiling on matches scraped in one run, applied after duplicates are removed.

This is your spend guard: a run can never bill more matches than this number. Raise it deliberately for a planned bulk retrieval — up to 10,000.

## `matchFormat` (type: `string`):

Choose `Singles only` to skip doubles matches.

Doubles are identified from match metadata and dropped before any point or statistics parsing, so **you are never billed for a match this filter removes**.

## `includePoints` (type: `boolean`):

Keep this on for the rally-level data — sets, games, and every point in play order, each with server, receiver, shot sequence, and break-point flags.

Turn it off for a lighter payload when you only want scorelines and statistics. The price per match is the same either way.

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

Keep this on for the full statistics table — serve, return, break points, and shot-quality ratings, at both match and set level. A typical match carries 40–90 statistic rows.

Turn it off for a lighter payload. The price per match is the same either way.

## Actor input object example

```json
{
  "operation": "scrape-matches",
  "playerIds": [
    "feca401d-e9c2-4638-93b0-878fdb9aea74"
  ],
  "search": "Dinev",
  "maxPlayers": 100,
  "matchRequests": [],
  "maxMatchesPerPlayer": 5,
  "maxTotalMatches": 20,
  "matchFormat": "all",
  "includePoints": true,
  "includeStats": true
}
```

# Actor output Schema

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

No description

## `summary` (type: `string`):

No description

## `failures` (type: `string`):

No description

# 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 = {
    "playerIds": [
        "feca401d-e9c2-4638-93b0-878fdb9aea74"
    ],
    "search": "Dinev"
};

// Run the Actor and wait for it to finish
const run = await client.actor("sian.agency/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 = {
    "playerIds": ["feca401d-e9c2-4638-93b0-878fdb9aea74"],
    "search": "Dinev",
}

# Run the Actor and wait for it to finish
run = client.actor("sian.agency/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("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

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

```

## CLI example

```bash
echo '{
  "playerIds": [
    "feca401d-e9c2-4638-93b0-878fdb9aea74"
  ],
  "search": "Dinev"
}' |
apify call sian.agency/tennis-point-by-point-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=sian.agency/tennis-point-by-point-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

````json
{
    "openapi": "3.0.1",
    "info": {
        "title": "Tennis Point-by-Point Scraper — Rally & Match Stats",
        "description": "Point-by-point tennis data no other scraper sells — every serve, rally, and break point of a match, in play order, plus the complete match- and set-level statistics table. Find players by name, then export full match records as JSON, CSV, or Excel. No API key.",
        "version": "1.0",
        "x-build-id": "k9B1THG9aOdcfZePe"
    },
    "servers": [
        {
            "url": "https://api.apify.com/v2"
        }
    ],
    "paths": {
        "/acts/sian.agency~tennis-point-by-point-scraper/run-sync-get-dataset-items": {
            "post": {
                "operationId": "run-sync-get-dataset-items-sian.agency-tennis-point-by-point-scraper",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for its completion, and returns Actor's dataset items in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        },
        "/acts/sian.agency~tennis-point-by-point-scraper/runs": {
            "post": {
                "operationId": "runs-sync-sian.agency-tennis-point-by-point-scraper",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor and returns information about the initiated run in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/runsResponseSchema"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/acts/sian.agency~tennis-point-by-point-scraper/run-sync": {
            "post": {
                "operationId": "run-sync-sian.agency-tennis-point-by-point-scraper",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for completion, and returns the OUTPUT from Key-value store in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "inputSchema": {
                "type": "object",
                "required": [
                    "operation"
                ],
                "properties": {
                    "operation": {
                        "title": "🎯 Operation — what do you want to do?",
                        "enum": [
                            "scrape-matches",
                            "discover-players",
                            "discover-matches",
                            "scrape-match-ids"
                        ],
                        "type": "string",
                        "description": "🎯 **Pick one operation per run.** Each run produces one clean dataset matching the chosen mode.\n\n- **🎾 Scrape a player's matches** — the main event. Full matches: every point in play order plus the complete statistics table\n- **🔎 Find players** — look up a player by name to get their ID\n- **📋 Build a match list** — collect unique match IDs for a roster without scraping them yet (not billed)\n- **📋 Scrape specific matches** — scrape match IDs you already have\n\n💡 **Just hit Start.** A player is already filled in below, so a default run returns real matches straight away.\n\n🔎 **Scraping someone else?** Switch to `Find players`, search their name, copy the `playerId` from the results into 🎾 Player IDs, then switch back here.\n\n🧭 **For big jobs:** `Build a match list` first — it deduplicates matches shared between players so you never pay for the same match twice — then feed the IDs to `Scrape specific matches` in batches.",
                        "default": "scrape-matches"
                    },
                    "playerIds": {
                        "title": "🎾 Player IDs",
                        "maxItems": 500,
                        "type": "array",
                        "description": "🎾 **Required for `Scrape a player's matches` and `Build a match list`.** Ignored by the other operations.\n\nPaste one or more player IDs from your `Find players` results. Add as many as you like — up to 500 — and a match shared by two of them is fetched, and billed, only once.\n\n💡 Not every player in the directory has matches on file; coverage is strongest on ATP Challenger and ITF events. Run `Build a match list` first to see what exists for your roster without being billed for it.",
                        "items": {
                            "type": "string",
                            "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
                        },
                        "default": []
                    },
                    "search": {
                        "title": "🔎 Player name",
                        "type": "string",
                        "description": "🔎 **Used by `Find players`.** Ignored by every other operation.\n\nType part of a player's name — first name, surname, or both. Matching is partial and case-insensitive, so `dinev` and `Dinev` both work.\n\n⚠️ **Leave this empty and you get the whole directory — about 2,600 players, billed per row.** Keep a name here unless you deliberately want the full list.\n\n💡 **Next step:** copy the `playerId` from your results into 🎾 Player IDs, then switch the operation back to `Scrape a player's matches`.",
                        "default": "Dinev"
                    },
                    "maxPlayers": {
                        "title": "Player limit",
                        "minimum": 1,
                        "maximum": 10000,
                        "type": "integer",
                        "description": "The hard ceiling on player rows returned by `Find players`.\n\nThis is your spend guard: every player row is billed, and an empty name field matches the whole directory of roughly 2,600 players. Raise it when you genuinely want a bigger slice — up to 10,000.",
                        "default": 100
                    },
                    "matchRequests": {
                        "title": "📋 Match IDs to scrape",
                        "maxItems": 250,
                        "type": "array",
                        "description": "📋 **Required for `Scrape specific matches`.** Ignored by every other operation — most users never touch this field.\n\nUse it only when you already hold match IDs, typically from a `Build a match list` run, and want to scrape them in controlled batches. Up to 250 per run.\n\nEach entry takes a `matchId`, and optionally the players it belongs to:\n\n```json\n[{ \"matchId\": \"313b5434-cffc-4938-804c-e6edeec28e8e\" }]\n```\n\n💡 **Scraping one player?** Ignore this and use `Scrape a player's matches` instead.",
                        "items": {
                            "type": "object",
                            "required": [
                                "matchId"
                            ],
                            "properties": {
                                "matchId": {
                                    "title": "Match ID",
                                    "description": "A match ID from a `Build a match list` run.",
                                    "type": "string",
                                    "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
                                },
                                "requestedPlayerIds": {
                                    "title": "Player IDs this match belongs to",
                                    "description": "Optional. Carried through to the output so you can trace a match back to the players you asked for.",
                                    "type": "array",
                                    "default": [],
                                    "items": {
                                        "type": "string",
                                        "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
                                    }
                                }
                            }
                        },
                        "default": []
                    },
                    "maxMatchesPerPlayer": {
                        "title": "Matches per player",
                        "minimum": 0,
                        "maximum": 10000,
                        "type": "integer",
                        "description": "Cap how many matches to take from each player's history. Set `0` to take every match on file.\n\nStarts at `5` so your first run is quick and cheap. Raise it once you have seen the data — there is no upper limit beyond the total cap below.",
                        "default": 5
                    },
                    "maxTotalMatches": {
                        "title": "Total match limit",
                        "minimum": 1,
                        "maximum": 10000,
                        "type": "integer",
                        "description": "The hard ceiling on matches scraped in one run, applied after duplicates are removed.\n\nThis is your spend guard: a run can never bill more matches than this number. Raise it deliberately for a planned bulk retrieval — up to 10,000.",
                        "default": 20
                    },
                    "matchFormat": {
                        "title": "Singles or doubles",
                        "enum": [
                            "all",
                            "singles"
                        ],
                        "type": "string",
                        "description": "Choose `Singles only` to skip doubles matches.\n\nDoubles are identified from match metadata and dropped before any point or statistics parsing, so **you are never billed for a match this filter removes**.",
                        "default": "all"
                    },
                    "includePoints": {
                        "title": "Include point-by-point data",
                        "type": "boolean",
                        "description": "Keep this on for the rally-level data — sets, games, and every point in play order, each with server, receiver, shot sequence, and break-point flags.\n\nTurn it off for a lighter payload when you only want scorelines and statistics. The price per match is the same either way.",
                        "default": true
                    },
                    "includeStats": {
                        "title": "Include match & set statistics",
                        "type": "boolean",
                        "description": "Keep this on for the full statistics table — serve, return, break points, and shot-quality ratings, at both match and set level. A typical match carries 40–90 statistic rows.\n\nTurn it off for a lighter payload. The price per match is the same either way.",
                        "default": true
                    }
                }
            },
            "runsResponseSchema": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "id": {
                                "type": "string"
                            },
                            "actId": {
                                "type": "string"
                            },
                            "userId": {
                                "type": "string"
                            },
                            "startedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "finishedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "status": {
                                "type": "string",
                                "example": "READY"
                            },
                            "meta": {
                                "type": "object",
                                "properties": {
                                    "origin": {
                                        "type": "string",
                                        "example": "API"
                                    },
                                    "userAgent": {
                                        "type": "string"
                                    }
                                }
                            },
                            "stats": {
                                "type": "object",
                                "properties": {
                                    "inputBodyLen": {
                                        "type": "integer",
                                        "example": 2000
                                    },
                                    "rebootCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "restartCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "resurrectCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "computeUnits": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "options": {
                                "type": "object",
                                "properties": {
                                    "build": {
                                        "type": "string",
                                        "example": "latest"
                                    },
                                    "timeoutSecs": {
                                        "type": "integer",
                                        "example": 300
                                    },
                                    "memoryMbytes": {
                                        "type": "integer",
                                        "example": 1024
                                    },
                                    "diskMbytes": {
                                        "type": "integer",
                                        "example": 2048
                                    }
                                }
                            },
                            "buildId": {
                                "type": "string"
                            },
                            "defaultKeyValueStoreId": {
                                "type": "string"
                            },
                            "defaultDatasetId": {
                                "type": "string"
                            },
                            "defaultRequestQueueId": {
                                "type": "string"
                            },
                            "buildNumber": {
                                "type": "string",
                                "example": "1.0.0"
                            },
                            "containerUrl": {
                                "type": "string"
                            },
                            "usage": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "integer",
                                        "example": 1
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "usageTotalUsd": {
                                "type": "number",
                                "example": 0.00005
                            },
                            "usageUsd": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "number",
                                        "example": 0.00005
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
````
