Free Sports APIs: I Curl-Tested 15, 8 Need No Key


My scoreboard script printed three games and then died. Not on a timeout, not on a 500. On this:

2026-07-17  (15 games, HTTP 200)
   Tampa Bay Rays 0 - 10 Boston Red Sox
   Tampa Bay Rays 3 - 5 Boston Red Sox
   Los Angeles Dodgers 2 - 1 New York Yankees

2026-07-20  (15 games, HTTP 200)
   KeyError: 'score'  <- the key is GONE, not null, not 0

Same endpoint. Same keyless API. Same shape of request. Fifteen valid game objects came back for July 20, HTTP 200, every one of them with teams, venue and a start time. None of them had a score field. Not "score": null. Not "score": 0. The key was simply absent. All fifteen of those games were still in the Scheduled state.

That is real stdout from a twelve-line script I ran on July 19, 2026, against statsapi.mlb.com. It is the whole article in one paste.

The one idea in this post

A free sports API returns schedules, scores and standings with no API key, no signup, no card. Eight of them clear that bar and I re-verified every one with a live curl on July 19, 2026. The table is one screen down.

Under it sits the actual lesson, and it is a new one for me: the shape of the JSON object depends on hidden state. Not the values. The shape. A game that has finished and a game that starts in six hours are the same resource, from the same URL, with the same status code, and they do not have the same keys. Infer your schema from one and it breaks on the other.

Earlier stops in this series argued about what a 200 body contained: a null that passed schema-check, a field whose name promised more than it counted, a timestamp in the wrong unit, a 404 that was not a fact about the word. This one is different. Here the field you validate against is not wrong. It is not there. Your KeyError fires in production at 7pm and never fires in your morning test run, because in the morning the games were finished.

The 8, at a glance

Everything here returned HTTP 200 with no key from my host on July 19, 2026. I sent a plain User-Agent and nothing else: no Authorization, no ?key=.

#APIWhat it returnsThe gotcha
1MLB StatsAPISchedules, live scores, standings, rostersscore key is absent on Scheduled games and present-but-0 on Pre-Game. officialDate is the real date, not gameDate
2NHL Web APIScores, standings, schedules, team stats/v1/score/now is a 307. In July it redirected me to 2026-09-29, so “now” meant next season
3ESPN site API (live endpoint)Scoreboards for MLB, NBA, soccer and moreUndocumented and unannounced. Scores come back as strings: ['1', '5']
4OpenLigaDBGerman football, Bundesliga back to the 1960smatchResults is a list of periods, not one score. First match of 2024 had 2 entries
5Jolpica-F1Formula 1 results, drop-in Ergast replacementThe old ergast.com host is dead (522). Update your base URL
6Chess.com Published DataPlayer profiles, leaderboards, game archivesscore on a leaderboard is a rating, not a result. Hikaru sat at 3430
7LichessUsers, ratings, tournaments, opening explorerMany endpoints stream NDJSON, not JSON. Read the Accept header docs
8Fantasy Premier League (live endpoint)841 players, 20 teams, points, fixturesUndocumented and unversioned. Haaland led on 239 points

Numbers 1, 4, 5, 6 and 7 are the ones I would build on: real projects behind them, stable paths, and in Lichess and Chess.com’s case an explicit public-data policy. Numbers 3 and 8 work beautifully and have no contract with you at all. Number 2 has a redirect worth understanding before you cache anything.

Why the score field disappears

On MLB StatsAPI, the score key is not always present, and neither its presence nor its value reliably tells you a game was played. Here is the naive scoreboard, the one every tutorial shows you, pointed at two days: one in the past, one in the future.

# runnable, read-only: no key, no signup. the naive scoreboard, and where it dies.
import json, urllib.request
from datetime import date, timedelta

UA = {"User-Agent": "keyless-audit/1.0"}

def slate(d):
    url = f"https://statsapi.mlb.com/api/v1/schedule?sportId=1&date={d}"
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
        dates = json.load(r).get("dates", [])
    return dates[0]["games"] if dates else []

for offset in (-2, +1):
    d = date.today() + timedelta(days=offset)
    games = slate(d)
    print(f"\n{d}  ({len(games)} games, HTTP 200)")
    try:
        for g in games[:3]:                      # the line every tutorial shows you
            a, h = g["teams"]["away"], g["teams"]["home"]
            print(f"   {a['team']['name']} {a['score']} - {h['score']} {h['team']['name']}")
    except KeyError as e:
        print(f"   KeyError: {e}  <- the key is GONE, not null, not 0")
2026-07-17  (15 games, HTTP 200)
   Tampa Bay Rays 0 - 10 Boston Red Sox
   Tampa Bay Rays 3 - 5 Boston Red Sox
   Los Angeles Dodgers 2 - 1 New York Yankees

2026-07-20  (15 games, HTTP 200)
   KeyError: 'score'  <- the key is GONE, not null, not 0

Read the two blocks together. Identical code, identical endpoint, identical status. Past date: three scores, printed fine. Future date: the object exists, the teams exist, the key does not.

This matters more than a missing value would. A null survives json.loads, survives most schema checks, and shows up in your database as an honest blank. A missing key throws. If you built the pipeline in the evening against yesterday’s finished games, you shipped something that works, and it will keep working right up until it runs against a morning slate.

The obvious fix is also broken

So you guard it. if game["status"]["abstractGameState"] == "Final": and move on. That is what I did, and it is wrong, and it took a wider scan to see why.

# runnable, read-only: which states carry a score key, and which silently do not?
import json, urllib.request
from collections import defaultdict
from datetime import date, timedelta

UA = {"User-Agent": "keyless-audit/1.0"}
seen = defaultdict(lambda: {"key": 0, "nokey": 0, "abstract": set(), "vals": set()})

for i in range(-9, 1):
    d = date.today() + timedelta(days=i)
    url = f"https://statsapi.mlb.com/api/v1/schedule?sportId=1&date={d}"
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
        dates = json.load(r).get("dates", [])
    for g in (dates[0]["games"] if dates else []):
        st, away = g["status"], g["teams"]["away"]
        row = seen[st["detailedState"]]
        row["abstract"].add(st["abstractGameState"])
        if "score" in away:
            row["key"] += 1
            row["vals"].add(away["score"])
        else:
            row["nokey"] += 1

print(f"{'detailedState':<14}{'abstractGameState':<20}{'has score':<11}{'NO score key':<14}values")
for ds in sorted(seen):
    r = seen[ds]
    print(f"{ds:<14}{sorted(r['abstract'])[0]:<20}{r['key']:<11}{r['nokey']:<14}{sorted(r['vals'])[:8]}")
detailedState abstractGameState   has score  NO score key  values
Final         Final               61         0             [0, 1, 2, 3, 4, 5, 6, 7]
In Progress   Live                2          0             [1, 5]
Postponed     Final               0          2             []
Pre-Game      Preview             7          0             [0]
Scheduled     Preview             0          20            []
Warmup        Live                2          0             [0]

Ninety-four games over ten days. Look at row three.

A postponed game reports abstractGameState: "Final" and has no score key. Both of them, zero out of two. So the guard I just wrote, the one that looks obviously correct, still raises KeyError the first time a game gets postponed. I hit exactly two in ten days: Brewers at Pirates on July 10, Pirates at Guardians on July 17. Rare enough to pass every test you write. Common enough to page you in April.

Now look at row four, which I find worse. Pre-Game reports abstractGameState: "Preview" and it does have a score, and the value is 0. Seven for seven in that scan. Look at the last row too: Warmup reports abstractGameState: "Live" and also carries a 0, so the phantom zero is not confined to Preview either. So a game that has not thrown a pitch carries a complete, well-formed, entirely fake 0 - 0. If you filter on “does the key exist” instead of the status, you will ingest phantom ties. They will pass every null check you own, because they are not null. They are zero.

That is the part that changed how I think about this. The two failure modes point in opposite directions. Trust the status field and postponements crash you. Trust the key’s presence and pre-game zeros pollute you. The tidy field, abstractGameState, is the one that looks designed for exactly this job and is not.

So before stating a rule I widened the window, and I am glad I did, because my first version of this paragraph said “read detailedState instead” and stopped there. That is still not good enough. One request per season against startDate/endDate gives you thousands of games at a time; across 9,002 of them from the 2023 to 2025 seasons, abstractGameState: "Final" turns out to cover four different detailedState values that disagree with each other. Final (8,831 games) and Completed Early (26) all carry a score. Postponed (105) and Cancelled (40) carry none. My ten-day window never sampled a single cancellation, so the neat rule I was about to publish had a hole in it that only a wider scan showed.

And detailedState is not a rule either, it is a lookup table I have only partly measured. MLB’s own /api/v1/gameStatus endpoint lists 210 rows and 166 distinct detailedState values. I have observed eight. Which leaves the boring answer as the only honest one: check that the key is there before you read it, whatever any status field says. Use detailedState to decide what a result means, never to decide whether it is safe to index.

The date you grab is not the date that counts

Second gotcha on the same endpoint, and this one is quieter because nothing ever throws.

Each game carries gameDate, an ISO UTC timestamp. It is right there, it looks canonical, and slicing [:10] off it to get a day is the obvious move. Each game also carries officialDate, a plain date string that almost nobody reads.

# runnable, read-only: the date you grab vs the date that counts
import json, urllib.request
from datetime import date, timedelta

UA = {"User-Agent": "keyless-audit/1.0"}
total = wrong = 0

for i in range(-9, 1):
    d = date.today() + timedelta(days=i)
    url = f"https://statsapi.mlb.com/api/v1/schedule?sportId=1&date={d}"
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
        dates = json.load(r).get("dates", [])
    for g in (dates[0]["games"] if dates else []):
        total += 1
        naive, official = g["gameDate"][:10], g["officialDate"]   # UTC slice vs the real field
        if naive != official:
            wrong += 1
            if wrong <= 3:
                drift = "next day" if naive > official else "previous day"
                print(f"official={official}  gameDate={g['gameDate']}  -> naive slice says {naive} ({drift})")
                print(f"   {g['teams']['away']['team']['name']} @ {g['teams']['home']['team']['name']}")

print(f"\n{wrong} of {total} games ({wrong*100//total}%) land on the wrong calendar day if you slice gameDate.")
official=2026-07-10  gameDate=2026-07-11T00:05:00Z  -> naive slice says 2026-07-11 (next day)
   Houston Astros @ Texas Rangers
official=2026-07-10  gameDate=2026-07-11T00:10:00Z  -> naive slice says 2026-07-11 (next day)
   Los Angeles Angels @ Minnesota Twins
official=2026-07-10  gameDate=2026-07-11T00:15:00Z  -> naive slice says 2026-07-11 (next day)
   Atlanta Braves @ St. Louis Cardinals

23 of 94 games (24%) land on the wrong calendar day if you slice gameDate.

Nearly a quarter. A 7:05pm first pitch in Texas is 00:05Z the following day, so a lot of evening games in the Americas file themselves under tomorrow, depending on the time zone and the start time. Your “games on July 10” query returns nine, the box score in the newspaper says fifteen, and nothing anywhere raised an error.

It drifts both ways, which is what killed my first attempt at a fix. The block above prints only the first three mismatches and all three happen to be next-day, so you have to read further down the list to see it. I assumed a constant offset and tried to subtract a few hours. Then I found official=2026-07-11 with gameDate=2026-07-10T22:40:00Z, a game whose UTC stamp is the previous day. That one was a makeup for the postponement, scheduled early. There is no offset to apply. officialDate is the answer, and it is the answer because MLB says so, not because it is derivable.

The takeaway, in one line

A 200 means your request succeeded. It does not mean a game was played, that the object has the keys you saw last time, or that it belongs to the day you asked for. Read detailedState, check in before you index, and group by officialDate.

Three lines of defensiveness, and you can write them now:

def final_score(game):
    """None unless the game is a completed, regular Final. Never raises."""
    # Deliberately strict: "Game Over" and "Completed Early" also carry real
    # scores. Add them to the allowlist if you need them; do not drop the
    # membership check below, which is what actually keeps this from raising.
    if game["status"]["detailedState"] != "Final":     # NOT abstractGameState
        return None
    away, home = game["teams"]["away"], game["teams"]["home"]
    if "score" not in away or "score" not in home:     # postponed, suspended, weird
        return None
    return away["score"], home["score"], game["officialDate"]

The other seven, honestly

The list is genuinely useful. I just will not pretend every host owes you anything.

Chess.com and Lichess are the two I trust most, and neither is a scoreboard. Chess.com publishes a documented read-only data API with no key at all; its leaderboard score is a rating, so the 3430 next to Hikaru is not a match result. Lichess is the same idea with a bigger surface, plus a detail that trips people: several of its endpoints stream NDJSON, one JSON object per line, and json.loads() on the whole body fails. Read the docs on Accept before you debug your parser.

OpenLigaDB is a community German football database going back decades and it is my favorite structural gotcha here after MLB. matchResults is not a score, it is a list of scoring periods. The 2024 Bundesliga opener came back with 2 entries, halftime and full time. Grab [0] and you have published the halftime score as the final.

Jolpica-F1 is the Ergast successor and the migration is real, not theoretical: I curled the old ergast.com today and got 522, origin down. Jolpica kept the path structure, so /ergast/f1/2025/last/results.json still works and returned the 2025 Abu Dhabi Grand Prix, round 24, Verstappen in P1. Change the host, keep your code.

ESPN’s site API is the most useful thing on this list and the least promised. It powers their own scoreboards, it is not documented anywhere, and nobody at ESPN has told you it will exist tomorrow. It also hands back scores as strings: I got ['1', '5'], not [1, 5]. Sort those and 10 comes before 2.

Fantasy Premier League is in the same category. One call to bootstrap-static gave me 841 players and 20 teams with points, prices and fixtures. It is unversioned and undocumented, and it is the backbone of a large amateur analytics scene anyway.

The NHL API has the vintage of the bunch. /v1/score/now is a 307, and in mid-July it redirected me to /v1/score/2026-09-29, opening night of the next season. In the offseason, “now” did not mean today. It resolved to the next date with hockey on it. Cache that response in the offseason and your app will confidently show September fixtures as live.

Keyless attempt, key required

These looked free until I sent the request, on July 19, 2026:

  • balldontlie returned 401, body Unauthorized. It used to be the go-to keyless NBA API and it is not one any more. Free key on signup.
  • football-data.org is the interesting one. /v4/competitions returned 200 with a list of leagues, no key. Then /v4/competitions/PL/matches returned 403: The resource you are looking for is restricted and apparently not within your permissions. Please check your subscription. The wrapper is open, the actual match data is not. If you smoke-test an API by hitting its index route, this is how you conclude it is keyless and find out otherwise in production.
  • TheSportsDB returned 200 on /api/v1/json/3/searchteams.php?t=Arsenal, and I still left it off the list. The 3 in that path is a shared public test key. You did not register, but it is not yours, it is rate-limited across everyone using it, and v2 wants a real one. Keyless-looking, not keyless.
  • stats.nba.com returned 000. The connection was cut before any response, which is their anti-bot filter doing its job against a datacenter IP.
  • cdn.nba.com returned 403 from the Akamai edge. Both NBA endpoints are keyless in principle and were unusable from my host with a plain request. Use ESPN’s NBA scoreboard instead.

The one that changes its mind: OpenF1

OpenF1 deserves its own paragraph because it is this article’s thesis at the auth layer.

I called /v1/sessions today and got 200 with real session data, no key. Their site says it plainly: “No API keys, no credit cards” and “No authentication required” for the free tier. So far, a clean keyless API.

But the free tier is historical data. Their supporter tier is described as “everything from the free tier plus live data during sessions”, and a keyless call has been observed returning 401 during a live F1 session, with a message saying global API access is restricted to authenticated users until the session ends. I could not reproduce that today because no session was running, so I am reporting it as a caveat rather than a measurement, and that is exactly why OpenF1 is not one of my eight.

Think about what that means for a naive integration. The endpoint is keyless on a Tuesday and 401s on a Sunday afternoon, which is the only time anyone actually wants F1 data. Same URL, same client, different answer, and if that report is accurate the variable is the race calendar. Whether it belongs on a keyless list depends on when you tested, and that should bother you.

Dead, blocked, or moved

For the record, on July 19, 2026: ergast.com, the F1 API a decade of tutorials still points at, returned 522. It was retired at the end of 2024. Every blog post telling you to curl it is wrong, and the fix is one hostname.

What I could not verify

I cannot tell you why the score key is omitted rather than sent as null. The behavior is consistent with the response being serialized from an object where the field is genuinely unset until a game starts, but MLB does not publish a schema and I do not have their code. That is an inference, and I would rather label it than dress it up.

I also cannot promise the undocumented hosts stay up. ESPN’s site API and the FPL endpoint are load-bearing for a lot of hobby projects and neither has any public commitment behind it. My eight were all 200 today. Two of them are 200 at somebody’s discretion.

The OpenF1 401 is second-hand, as I said above. I saw 200.

And a fair objection to me specifically: I run web scrapers in production, 2,190 runs across 32 published actors, 962 of them on one Trustpilot scraper. I have not run a sports-data pipeline in production, and nothing here rests on those runs. They are only where the reflex comes from, the habit of checking whether a field exists before trusting what it says. The evidence in this post is the stdout above, and you can regenerate every line of it.

About those blocks: they will drift

The scores, the team names, the exact counts in my output are live data and I cannot promise you the same numbers tomorrow. Different day, different slate, different postponements. Do not trust my exact stdout. Run the blocks.

What is reproducible is the check, and it is written so you re-derive the mechanism rather than take my word: the code walks a window ending on your date.today() and prints which states carry a score key on your run. If you get 19 mismatched dates instead of 23, or three postponements instead of two, you have reproduced the finding, not broken it. The claim is not “24 percent”. The claim is “gameDate and officialDate disagree on a large minority of games, in both directions”. The percentage is just what it looked like on my ten days.


If you consume a sports feed today, here is the question I actually want answered, because I do not have a clean method for it: how do you write a schema check that distinguishes “this field is missing because the event has not happened” from “this field is missing because the provider changed the response”? Both look identical to a validator. Optional-everything schemas accept genuine breakage; strict schemas page you every evening at first pitch. Every fix I can think of trades one of those for the other.

What has worked for you?

Every endpoint above was verified with a live curl on July 19, 2026, from my own host, with no API key. Earlier in this series: free dictionary and word APIs, 15 keyless transit APIs and a timestamp their docs get wrong, free space and astronomy APIs. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.


More production scraping tips: t.me/scraping_ai