How to Get Live Sports Scores for Free Using ESPN's Hidden API
I build systems that blend AI and automation to solve real-world problems
If you've ever tried to build something that needs sports data — a score bot, a fantasy helper, a dashboard — you've probably run into the same wall. The official sports data APIs are expensive. SportsRadar starts at $9/month for hobbyists but quickly gets painful at scale. SportDataAPI, MySportsFeeds, same story. You're paying just to get data that ESPN is already serving to millions of people for free on their website.
Here's the thing: ESPN has an undocumented public API that's been sitting there for years. No authentication. No rate limit warnings. Clean JSON. Most developers don't know it exists because ESPN has never publicized it.
This post covers how it works, what you can pull from it, and how I wrapped it into a reusable tool so you don't have to deal with the sharp edges yourself.
The ESPN API Endpoints
The base URL pattern looks like this:
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard
So for NBA games today:
https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard
For NFL:
https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard
For MLB:
https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard
Hit any of those in your browser right now and you'll get a full JSON response with today's games — scores, game status, teams, venues, broadcast info.
The sport/league mapping
| Sport | League | Endpoint segment |
|---|---|---|
| Basketball | NBA | basketball/nba |
| Basketball | WNBA | basketball/wnba |
| Basketball | NCAA Men's | basketball/mens-college-basketball |
| Football | NFL | football/nfl |
| Football | NCAA | football/college-football |
| Baseball | MLB | baseball/mlb |
| Hockey | NHL | hockey/nhl |
| Soccer | MLS | soccer/usa.1 |
| Soccer | EPL | soccer/eng.1 |
| Soccer | La Liga | soccer/esp.1 |
| Soccer | Champions League | soccer/uefa.champions |
Fetching a specific date
By default the scoreboard endpoint returns today's games. To pull a different date, add a dates query parameter in YYYYMMDD format:
https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard?dates=20260401
For a date range, pass both dates separated by a dash... except it doesn't actually support ranges natively. You have to call it once per date and stitch the results together yourself. More on that in a minute.
What the response looks like
The raw response is nested and verbose. A single game object inside events[] looks roughly like this (simplified):
{
"id": "401584721",
"date": "2026-01-23T00:00Z",
"name": "Cleveland Cavaliers at Charlotte Hornets",
"competitions": [
{
"status": {
"clock": 0,
"period": 0,
"type": {
"state": "pre",
"description": "Scheduled",
"detail": "7:00 PM ET"
}
},
"competitors": [
{
"homeAway": "home",
"team": {
"id": "30",
"displayName": "Charlotte Hornets",
"abbreviation": "CHA"
},
"score": "0"
},
{
"homeAway": "away",
"team": {
"id": "5",
"displayName": "Cleveland Cavaliers",
"abbreviation": "CLE"
},
"score": "0"
}
],
"venue": {
"fullName": "Spectrum Center",
"address": { "city": "Charlotte", "state": "NC" }
},
"broadcasts": [
{ "names": ["NBA TV"] }
]
}
]
}
You have to dig through competitions[0].competitors[] to get home/away teams, navigate competitions[0].status.type.state to get game status, flatten broadcasts out of a nested array. It's not unusable but it's not clean either.
Where It Gets Annoying
The API itself is solid, but using it directly at any real scale has a few friction points.
No date ranges. You want last week's NFL results? That's 7 separate HTTP calls, one per day, that you have to stitch together manually.
Multi-league runs. Want NBA + NHL + MLB in one shot? Three separate API calls, three different response shapes to normalize.
Timezone handling. Dates come back in UTC. If you're building something for US users you're converting everything yourself.
The nested response shape. Every time you use this you're writing the same flattening logic: dig into competitions[0], separate home from away via homeAway field, pull status from type.state, flatten broadcasts. It's 20-30 lines of boilerplate every time.
None of this is hard but it's tedious, and it's the kind of thing that breaks your flow when you're trying to build the actual thing you care about.
What I Built
I got tired of rewriting this logic across different projects, so I packaged it as an Apify actor.
You give it a list of leagues and a date range, it handles the multi-league calls, normalizes everything into a flat consistent schema, converts timezones, and outputs clean JSON you can actually use.
Input looks like this:
{
"leagues": ["nba", "nhl", "mlb"],
"dateMode": "today",
"timezone": "America/New_York"
}
And each game comes out flat:
{
"gameId": "401584721",
"sport": "basketball",
"league": "nba",
"date": "2026-01-23T00:00:00Z",
"dateLocal": "2026-01-22T19:00:00-05:00",
"name": "Cleveland Cavaliers at Charlotte Hornets",
"shortName": "CLE @ CHA",
"status": {
"state": "pre",
"description": "Scheduled",
"detail": "7:00 PM ET"
},
"homeTeam": {
"name": "Charlotte Hornets",
"abbreviation": "CHA",
"score": null
},
"awayTeam": {
"name": "Cleveland Cavaliers",
"abbreviation": "CLE",
"score": null
},
"venue": {
"name": "Spectrum Center",
"city": "Charlotte",
"state": "NC"
},
"broadcasts": ["NBA TV"]
}
No nested competitions[0], no manual timezone math, no stitching date loops.
Using the Output
Once you have clean JSON coming out, plugging it into things is straightforward.
Pull today's scores with Python
import requests
run_url = "https://api.apify.com/v2/acts/hgservices~apify-actor-espn/run-sync-get-dataset-items"
params = {
"token": "YOUR_APIFY_TOKEN"
}
payload = {
"leagues": ["nba", "nfl"],
"dateMode": "today",
"timezone": "America/New_York"
}
response = requests.post(run_url, json=payload, params=params)
games = response.json()
for game in games:
status = game["status"]["detail"]
print(f"{game['shortName']} — {status}")
Post live scores to a Discord webhook
import requests
DISCORD_WEBHOOK = "https://discord.com/api/webhooks/YOUR_WEBHOOK"
def post_scores(games):
lines = []
for game in games:
if game["status"]["state"] == "in":
home = game["homeTeam"]
away = game["awayTeam"]
lines.append(
f"**{away['abbreviation']} {away['score']} @ {home['abbreviation']} {home['score']}** — {game['status']['detail']}"
)
if lines:
requests.post(DISCORD_WEBHOOK, json={"content": "\n".join(lines)})
# Call this on a schedule
Filter by team across a date range
{
"leagues": ["nba"],
"dateMode": "range",
"startDate": "20260401",
"endDate": "20260430",
"teams": ["LAL"],
"timezone": "America/Los_Angeles"
}
Returns every Lakers game in April — useful for building team-specific trackers or automating schedule reminders.
Scheduling It
On Apify you can set cron schedules directly on the actor. A few useful patterns:
| Use case | Cron |
|---|---|
| Morning game preview | 0 8 * * * |
| Live score updates | */5 * * * * |
| Nightly results digest | 0 2 * * * |
| Weekly schedule pull | 0 8 * * 1 |
The live score polling every 5 minutes is the most common pattern for Discord/Slack bots. Costs almost nothing — you're fetching maybe 10-20 game results per run at $0.001 each.
Wrapping Up
ESPN's public API is genuinely good. The data is reliable, it covers all the major US leagues plus EPL and Champions League, and there's no auth to deal with. The main friction is the normalization work every time you use it — which is what the actor handles.
If you're just exploring the API directly, the endpoints above are enough to get started. If you want clean output across multiple leagues without writing the plumbing yourself, the Apify actor is available at $0.001 per result — running today's full NBA schedule costs you about 2 cents.
Happy to answer questions in the comments about the ESPN API structure or how the actor handles edge cases like postponed games and timezone conversion.