DEV Community

Nikita Iakovlev
Nikita Iakovlev

Posted on

A Walkover, a Retirement and a Straight-Sets Win All Arrive With the Same Status Code

Visas pay my bills — I run an agency in Bali. The other half of my week goes into Apify Actors, and one of them is a tennis feed: pick a date and a set of tours, get one flat row per match with both players, set scores, tiebreaks and a one-line summary.

I expected the hard part to be blocking. It isn't — the whole professional day of tennis arrives in a single HTTP request, no browser, no key, and a run finishes in under ten seconds. The hard part turned out to be a question that sounds too simple to be a problem: is this match over?

80 rows from yesterday

Here is a real run, taken this morning (13 October 2026): yesterday's results, every tour, capped at 80 rows. 8.1 seconds end to end. The tour mix tells you where professional tennis actually happens on a Monday — 38 Challenger men, 20 Challenger women, 14 ITF men, 4 WTA, 4 ATP.

Breakdown by status:

status Rows
finished 73
retired 5
walkover 2

Seven of eighty — 8.75% — did not end the way a results table assumes. And every single one of those seven carries statusCode: 3. The same number as all 73 completed matches.

Two fields, and the obvious one is useless

The feed carries two state keys per match. The first is coarse: 1 scheduled, 2 live, 3 no longer scheduled. The second is the precise stage, and it is a 70-value enum — finished, retired, walkover, awarded, canceled, postponed, abandoned, suspended, plus live stages down to thirdSetTiebreak.

A cancelled match, a walkover, a retirement and a 6-4 6-2 win are all "no longer scheduled". The coarse field cannot tell them apart. The first version of my mapper read only that field, because it is the one that looks like the status field, and it labelled ten matches out of twenty-eight as finished with no score at all. A results table where a third of the rows are completed matches with an empty score is worse than no table, and it looks fine in a spot check because the first rows you scroll past are real.

So the mapper reads the precise stage and keeps it in the row twice: status for the stage name a human filters on, and played — a boolean that is true for exactly four stages:

const DONE_STAGES = new Set(['finished', 'retired', 'walkover', 'awarded']);
Enter fullscreen mode Exit fullscreen mode

played is the field to filter on, not status == "finished". Retirements and walkovers produced a winner and belong in a results table; filtering on finished silently drops 8.75% of yesterday's results, and those are not random — a disproportionate share of retirements happen at Challenger and ITF level, where bodies are held together with tape and a flight the next morning.

The scoreline that cannot exist

Here is one of the retirements, from the Catania Challenger:

{
  "matchId": "vy2Rjhqo",
  "tour": "CHALLENGER MEN",
  "tournament": "Catania",
  "status": "retired",
  "statusCode": 3,
  "statusStage": "retired",
  "statusStageCode": 8,
  "played": true,
  "homePlayer": "Cuenin S.",
  "awayPlayer": "Almazan Valiente I.",
  "homeSets": 1,
  "awaySets": 1,
  "score": "7-6(4) 3-6 3-4",
  "winner": "away",
  "winnerName": "Almazan Valiente I.",
  "summary": "Cuenin S. vs Almazan Valiente I., CHALLENGER MEN Catania — Almazan Valiente I. won after a retirement 7-6(4) 3-6 3-4"
}
Enter fullscreen mode Exit fullscreen mode

Read score and homeSets/awaySets without reading status and you have a man who won a best-of-three match one set to one, trailing 3-4 in the decider. Feed that into a sets-won model and it is not an outlier you can clip — it is a legal-looking record that drags every average it touches. Another one in the same run: 6-2 2-0, a match "won" in a set and a bit.

The walkovers are the opposite failure. score is null, sets is [], homeSets and awaySets are null — but winner is set and played is true:

{
  "matchId": "hb2HZnSE", "tour": "ATP", "tournament": "Shanghai",
  "status": "walkover", "statusStageCode": 9, "played": true,
  "homePlayer": "Schnaitter J./Wallner M.", "awayPlayer": "Buse I./Vallejo D.",
  "score": null, "sets": [], "homeSets": null, "awaySets": null,
  "winner": "home"
}
Enter fullscreen mode Exit fullscreen mode

A row with a winner and no score is correct here. If your pipeline treats a null score as a scrape failure, it will retry forever on a match that never started.

The mirror image shows up on the schedule side. Today's ATP and WTA card came back as 30 rows: 26 upcoming, 1 walkover, and 3 canceled — Bencic vs Zhang, Kalinskaya vs Zhang and Li vs Zheng, all three cancelled from the Wuhan card, all three still sitting in today's schedule feed with a start time. Publish that as "today's matches" and you have promised three matches that will not be played.

Four more things the source does, and why they bite

The doubles decider is not a set. One Wuhan doubles result came back 7-5 4-6 3-10. That third set is a 10-point match tiebreak, and the feed puts those points in the games column like any other set. homeSets: 1, awaySets: 2 is right; summing the games column for "games won" adds thirteen games that were never played.

There are two player ids and only one of them is stable. The feed carries a per-match entry id — different for the same player in two different matches — and a participant id that the search endpoint returns and the player page URL is built from. My first version used the entry id. Every player link it emitted answered 404 and every id failed to match a search result, which is exactly the class of bug that passes code review: the field is populated, the shape is right, the value is garbage.

Statistics are strings, and the names repeat. A played match carries around 58 statistic rows, in sections Match, Set 1, Set 2, grouped into Service, Return, Points and Games. Of the 116 values in that run, 66 were shaped 74% (29/39), 32 were plain integers, 12 were fractions like 6/7 and 6 were bare percentages. All strings. And the name is not unique — Aces appears three times in one match:

Section Home Away
Match 4 9
Set 1 1 5
Set 2 3 4

So the obvious flatten:

stats = {s["name"]: s["home"] for s in match["statistics"]}   # wrong
stats["Aces"]   # "3" — the last set, not the match
Enter fullscreen mode Exit fullscreen mode

Key on (section, group, name) or filter to section == "Match" first. Nothing errors if you don't; you just get the final set's number labelled as the match total.

Point-by-point only names the server twice per set. The block for that match held 2 sets and 18 games. Four of the 18 carried a server value — the first two games of each set. Tennis alternates serve, so the rest are derivable, but you have to do it yourself, and you have to do it per set because the order flips. One more detail worth knowing before you parse the point strings: they are always written home:away, not server:receiver, so 0:15 in a game served by the away player means the server won the point. The strings also carry inline markers like |B1|; I pass those through verbatim rather than invent a meaning for them.

Head-to-head has a related trap that I do fix in the mapper: the winner of each previous meeting is not a flag, it is a leading asterisk on the winning player's name. Left in place it quietly corrupts every join on player names, so it gets stripped into a proper winner field. The h2h scores are set counts only (2:0), not game scores.

Calling it

One POST, no key for the source, pay per row:

import requests

rows = requests.post(
    "https://api.apify.com/v2/acts/lergassy~tennis-scores-api/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={"mode": "results", "tours": ["all"], "daysBack": 1, "maxMatches": 80},
).json()

real = [r for r in rows if r["played"]]
clean = [r for r in real if r["status"] == "finished"]   # completed only
print(len(rows), len(real), len(clean))                 # 80 80 73
Enter fullscreen mode Exit fullscreen mode

It's on the Apify Store as Tennis Scores & Stats API — schedule, live, results, single match, player, rankings and search, one flat row per match at $0.0009 a row.

When this is the wrong tool

  • You need the round. The day feed does not carry it. A schedule row's round is null rather than a guess reverse-engineered from the draw size, and no amount of filtering will conjure it.
  • You need odds. Not in this source. Different feed, different article.
  • You need history. This reads the public live-score data for a date range. It is not an archive — if you want yesterday's closing state preserved, schedule your own runs now.
  • You are a broadcaster. The official tour feeds are licensed, come with an SLA and cost thousands a month. This is public live-score data turned into rows, and it is priced accordingly.

The portable lesson has nothing to do with tennis: when a feed gives you two fields for the same concept, the one that is easy to read is usually the one that was easy to write — a three-value bucket somebody added for colouring a web page. Find the enum underneath it and spend a day mapping it properly. The cost of getting it wrong is not an error in your logs. It is 8.75% of your rows being confidently, plausibly false.

Top comments (0)