DEV Community

Matvey
Matvey

Posted on

I Asked 18 ESPN Scoreboards for 'Today' in the Same Second and Got Five Different Dates

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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."}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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)