I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads ESPN's public data for 33 leagues: scores, schedules, standings, rosters, box scores and the DraftKings line, one flat row per game. No API key, no proxy, no browser — ESPN's own site loads plain JSON and that JSON is the whole product.
The hard part of that Actor was never parsing. It was the word today.
The sweep
On 21 October 2026 I hit the bare scoreboard endpoint for 18 leagues — no date parameter, nothing but the league path. First request out at 13:10:44 UTC, last one back at 13:10:48. Four seconds, one notion of "now".
Every payload says which slate it decided to send: a day.date field, or — for the American football leagues — a week.number instead.
| What ESPN called "now" | Leagues | Rows |
|---|---|---|
| 2026-10-20 | nhl, nba | 11, 3 |
| 2026-10-21 | wnba, laliga, ucl, mls, atp, wta, pga | 1, 1, 9, 1, 4, 2, 1 |
| 2026-10-22 | uel | 18 |
| 2026-10-23 | mlb, epl, seriea, bundesliga, f1 | 1, 1, 1, 1, 1 |
| 2026-10-24 | ufc | 1 |
no date, week: 7
|
nfl | 14 |
no date, week: 8
|
ncaaf | 17 |
Five distinct dates — 20 through 24 October — from one instant. Two leagues carry no date at all, only a week number, and those two disagree about which week it is: NFL says 7, college football says 8. Tennis is stranger still: ATP and WTA both reported day: 2026-10-21 and then handed back tournament events dated 16, 17 and 18 October — more on that below.
Nothing here is broken. ESPN's default isn't "today", it's "the slate a fan opening this page wants to see". For the NBA and NHL at 9am Eastern that is last night's results; for the Premier League and MLB mid-week, the next fixture, two days out; for the NFL, the current week — on a Wednesday, one game played and twelve on Sunday. Every league's product team made a reasonable call and no two made the same one.
For a board, a bot or an agent tool the default is unusable: you cannot print "tonight's games" when two leagues are answering about Tuesday and five about Friday. So my Actor never omits the date — every run sends an explicit dates= value for every league. Which raises the second question.
What a date parameter actually selects
dates=20261021 does not mean "games whose UTC timestamp starts with 2026-10-21". It means ESPN's day, and ESPN's day is US Eastern. Here is the whole NBA slate that request returns, sorted by start time:
2026-10-21T23:00Z ATL @ ORL Wed, October 21st at 7:00 PM EDT
2026-10-21T23:30Z CHI @ TOR Wed, October 21st at 7:30 PM EDT
2026-10-22T00:00Z UTAH @ MEM Wed, October 21st at 8:00 PM EDT
2026-10-22T00:30Z DAL @ HOU Wed, October 21st at 8:30 PM EDT
2026-10-22T02:00Z GS @ LAL Wed, October 21st at 10:00 PM EDT
2026-10-22T02:30Z SAC @ LAC Wed, October 21st at 10:30 PM EDT
Eleven games (six shown), one Eastern date, two UTC dates. Six of the eleven start after midnight UTC, and the last three tip off at 02:00 and 02:30 UTC. That is where the damage lives: a bot asking for "today" at 10am in Bali asks for the new UTC date, gets the next Eastern slate, and misses three games that are live on its own screen.
I measured the size of that drift on the Actor's own scheduled run. Input, 21 October:
{ "leagues": ["nfl", "nba", "epl"], "mode": "scoreboard", "days": 7 }
2.6 seconds, 89 rows — NBA 65, NFL 14, EPL 10. At $0.0028 a game row that is 25 cents. Then I bucketed the 89 startTime values two ways:
by UTC date by Eastern date
10-21 5 10-21 11
10-22 7 10-22 3
10-23 9 10-23 13
10-24 13 10-24 12
10-25 27 10-25 24
10-26 6 10-26 10
10-27 7 10-27 4
10-28 8 10-28 12
10-29 7
37 of 89 rows carry a UTC calendar date different from the ESPN day they belong to, all of them NBA (34) and NFL (3). European football contributes zero: a 19:00 kick-off in London is the same date in both clocks. And an eight-day request produced nine date buckets — seven rows landed on 29 October, a day I never asked about. They are the 28 October night games: 2026-10-29T00:00Z, statusDetail "Wed, October 28th at 8:00 PM EDT".
So startTime[:10] is not the game's day. Neither is anything you compute in your own timezone. The day is Eastern, and if you want a date column that matches what ESPN, the broadcast and the box score all call it, convert first:
from datetime import datetime, timedelta, timezone
EASTERN = timezone(timedelta(hours=-4)) # EDT; -5 from the first Sunday in November
def espn_day(start_time: str) -> str:
t = datetime.strptime(start_time, "%Y-%m-%dT%H:%MZ").replace(tzinfo=timezone.utc)
return t.astimezone(EASTERN).date().isoformat()
Or skip the arithmetic entirely: statusDetail already contains ESPN's own phrase for scheduled games ("Wed, October 21st at 7:00 PM EDT"), and the Actor's summary field quotes it.
The range parameter is gone
ESPN used to accept dates=20261021-20261028. In September 2026 it stopped. Every league path, every range, same answer:
HTTP 400
{"code":400,"message":"Failed to get events endpoint."}
So a range is now one request per day, fired in parallel and merged, event ids deduplicated on the way in, 45 days maximum. Three leagues × eight days is 24 HTTP calls, which is why that 89-row run still finished inside three seconds: ESPN answers a scoreboard in about 150 ms and nothing here needs a browser. If seven of eight days answer you get those seven, and the error row names the one that didn't.
I checked whether the dedupe earns its keep in a normal window: eight days of NBA and eight of NFL gave 65 and 14 unique events, zero returned by more than one day request. For team sports the day buckets are clean.
For tennis they are anything but.
One day of tennis is 163 rows across nine dates
Individual sports don't ship games, they ship tournaments, and ESPN hangs every match of the week off a tournament event. Ask ATP for the single day 21 October and you get four events — and those events contain 163 competitions:
atp, dates=20261021 → 4 events, 163 rows
10-17 16 10-22 22
10-18 17 10-23 19
10-19 25 10-24 6
10-20 26 10-25 6
10-21 26
96 of those matches were already final, 5 were live, 61 had not started. 26 of 163 are on the day I asked for. WTA does the same thing: 2 events, 124 rows, nine dates, 15 of them on the requested day. At $0.0028 a row, a one-day tennis request bills like a 163-game NFL season.
Same question, three other individual-sport leagues, same day: UFC, F1 and PGA returned zero events for 21 October, having each returned one for the bare no-date request. Their scoreboards answer about tournament windows, not days, and 21 October was not inside one.
The practical rule: for atp, wta, ufc, f1, nascar, indycar, pga and lpga, treat the date as a pointer into the calendar, not a filter. Ask for one day, then filter rows yourself — or let the Actor do it with onlyStatus set to scheduled, live or final — it filters before it bills — and cap the rest with maxItems:
import requests
rows = requests.post(
"https://api.apify.com/v2/acts/lergassy~espn-scores-api/run-sync-get-dataset-items",
params={"token": TOKEN},
json={"leagues": ["atp"], "mode": "scoreboard",
"dateFrom": "2026-10-21", "dateTo": "2026-10-21",
"onlyStatus": "live", "maxItems": 50},
).json()
for r in rows:
print(r["tournament"], "|", r["round"], "|", r["summary"])
Two smaller things. A game that has not started has homeScore: null, not 0 — ESPN sends "0" on fixtures, and a 0–0 in a results column reads like a real draw. And a league that cannot be read arrives as a free type: "error" row with the URL and the reason, rather than as a silently missing day — so if you filter on type == "game", count the error rows before you throw them away.
When this is the wrong tool
-
You need play-by-play or tracking data. Box scores, leaders, scoring plays and win probability come with
includeGameDetails; per-possession and tracking feeds are licensed products and this JSON does not carry them. - You need guaranteed uptime for in-play betting. No contract sits behind ESPN's site data. For money-on-the-line latency, buy a licensed feed.
- You need cricket or rugby. Not on these endpoints.
- You want to run it from your laptop in Southeast Asia. The same URLs answer 403 from several regions, Indonesia among them — which is the main reason the Actor lives on Apify's US egress and not on my desk.
The Actor is Sports Scores & Schedules API — 33 leagues, one flat row per game, $0.0028 a row, error rows free.
If you keep one number from this: five. That is how many different calendar dates 18 ESPN scoreboards gave me for the same second, when I asked them nicely and didn't specify. Specify.
Top comments (0)