Live matches
Find the matches in play, then follow their score, events and stats with a steady polling loop.
Following a live match takes two steps: find out which matches are in play, then poll the live endpoints for the ones you care about. There are no webhooks or streaming connections, so your code asks at a steady pace and stops at full time.
Every example sends your key in the X-Api-Key header. Keep that key on your server.
Which matches are live
Ask /matches for status=live. On a busy day there can be more than the default 20, so raise per_page:
curl "https://api.soccer-data.co/v1/matches?status=live&per_page=100" \
-H "X-Api-Key: YOUR_API_KEY"
Add competition_id or team_id to watch only the games you need. Each live match carries a live object with the clock:
{
"id": "match_0i2m68v7oaxa",
"competition_id": "competition_3pcc7lp44661",
"status": "live",
"utc_date": "2026-10-09T19:00:00.000Z",
"home_team": { "id": "team_7p28lyz7ql3i", "name": "West Ham United" },
"away_team": { "id": "team_v3nsjaq0vsxs", "name": "Queens Park Rangers" },
"score": { "home": 0, "away": 1 },
"live": {
"match_status": "first_half",
"elapsed_minutes": 29,
"period": "first_half",
"updated_at": "2026-10-09T19:29:14.093Z"
},
"live_odds_available": true
}
live is null for any match that isn't in play. In the first moments after kickoff, the fields inside it can also be null until the clock first updates, so check before you display them.
Refresh this list every minute or two to pick up new kickoffs and final whistles.
The live endpoints
| Endpoint | What it gives you |
|---|---|
/matches/{match_id}/live-stats |
Score, clock and running team stats. |
/matches/{match_id}/live-timeline |
Goals, cards and substitutions so far. Filter with event_type, period or team_id. |
/matches/{match_id}/live-player-stats |
Each player's numbers so far. Filter with player_ids (comma-separated). |
/matches/{match_id}/odds/live |
In-play prices, when live_odds_available is true. Needs a paid plan. |
Score and stats
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-stats" \
-H "X-Api-Key: YOUR_API_KEY"
{
"data": {
"match_id": "match_0i2m68v7oaxa",
"meta": {
"match_status": "first_half",
"elapsed_minutes": 29,
"home_goals": 0,
"away_goals": 1,
"period": "first_half"
},
"stats": {
"ball_possession": { "all": { "home": 55, "away": 45 } },
"total_shots": { "all": { "home": 1, "away": 2 } },
"shots_on_target": { "all": { "home": 0, "away": 1 } },
"expected_goals": { "all": { "home": 0.06, "away": 0.85 } }
}
}
}
Which stats appear depends on the match. Check that a key exists before you read it.
Events
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-timeline" \
-H "X-Api-Key: YOUR_API_KEY"
{
"data": {
"match_id": "match_0i2m68v7oaxa",
"coverage": "full",
"events": [
{
"sequence": 1,
"minute": 17,
"extra_time": 0,
"period": "first_half",
"type": "yellow_card",
"team": { "id": "team_7p28lyz7ql3i", "name": "West Ham United" },
"player": { "id": "player_hjikl8w59an8", "name": "Arne Engels" }
},
{
"sequence": 2,
"minute": 18,
"extra_time": 0,
"period": "first_half",
"type": "penalty_scored",
"team": { "id": "team_v3nsjaq0vsxs", "name": "Queens Park Rangers" },
"player": { "id": "player_zqov38ls8d5o", "name": "Nicolas Madsen" }
}
]
},
"meta": {
"total": 6,
"coverage": "full",
"last_updated": "2026-10-09T20:30:03.372Z",
"match_status": "live"
}
}
A few things to watch for:
- Penalties are their own event type. A goal from the spot is
penalty_scored, notgoal. To get every goal, ask forevent_type=goal,penalty_scored. - Shoot-outs use the same type. Kicks in a penalty shoot-out are also
penalty_scored, but withperiodset topenalties. Leave them out when you count goals in the match. - Stoppage time is split in two. A goal in the 45+3rd minute has
minute: 45andextra_time: 3.
Player stats
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-player-stats?player_ids=player_5lxcg0mhwqx3" \
-H "X-Api-Key: YOUR_API_KEY"
{
"data": [
{
"player_id": "player_5lxcg0mhwqx3",
"player_name": "Ronnie Edwards",
"team_id": "team_v3nsjaq0vsxs",
"position": "M",
"rating": 6.84,
"started": true,
"minutes_played": 25,
"passing": { "total_passes": 7, "accurate_passes": 7, "key_passes": 0 },
"shooting": { "goals": 0, "total_shots": 0, "expected_assists": 0.01 }
}
]
}
Each entry also has a general group with touches, fouls and cards.
How often to poll
Live answers are cached on our side, and how long depends on your plan:
| Plan | Live data normally refreshed every |
|---|---|
| Free | 5 minutes |
| Pro and Business | 15 seconds |
Polling faster than your plan's refresh returns the same answer again, and each call still counts as a request. The X-Cache header tells you whether an answer came from the cache (HIT) or was fetched fresh (MISS). If a fresh answer can't be fetched, you may get an older copy marked STALE, with its age in X-Cache-Age: see When an answer is stale. Polling every 15 to 30 seconds is plenty on a paid plan.
Plan your budget before you build. On Pro, polling one endpoint every 15 seconds through a two-hour match uses about 480 requests; polling every 30 seconds halves that. On Free, one endpoint every 5 minutes uses about 24. See Rate limits for your plan's numbers.
To keep the count down:
- Poll only the matches someone is actually looking at.
- Choose the endpoint you need. If you only show the score,
live-statsalone is enough. - When you get a
429, wait the number of seconds in theRetry-Afterheader before trying again.
Before kickoff and after full time
The live endpoints only answer while a match is in play.
| When | live-stats |
live-timeline and live-player-stats |
odds/live |
|---|---|---|---|
| Before kickoff | 409 |
409 |
404 |
| In play | 200 |
200 |
200 when prices exist |
| After full time | 200 with match_status: "finished" for up to 5 minutes, then 409 |
409 |
404 |
The 409 has the code CONFLICT and the message "Match is not live". Treat it as a signal to switch endpoints, not as a failure.
Once the match is over, use the full-match endpoints instead: /stats, /player-stats and /timeline. These work the other way round: while the match is still live they return 409, with the code MATCH_IS_LIVE on /stats.
When to stop
Stop polling a match when either of these happens:
meta.match_statusinlive-statsisfinished.- A live endpoint returns
409after the match has started.
A simple polling loop
const headers = { "X-Api-Key": process.env.SOCCER_DATA_KEY };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function follow(matchId) {
while (true) {
const res = await fetch(`https://api.soccer-data.co/v1/matches/${matchId}/live-stats`, { headers });
if (res.status === 409) return; // not live (any more)
if (res.status === 429 || res.status === 503) {
await sleep(Number(res.headers.get("Retry-After") ?? 30) * 1000);
continue;
}
if (!res.ok) throw new Error(`Live stats failed with ${res.status}`);
const { data } = await res.json();
console.log(`${data.meta.elapsed_minutes}' ${data.meta.home_goals}-${data.meta.away_goals}`);
if (data.meta.match_status === "finished") return;
await sleep(30_000);
}
}
await follow("match_0i2m68v7oaxa");
Lineups
You don't have to wait for kickoff for lineups. /matches/{match_id}/lineups has a predicted lineup before the match, and the official team sheet usually arrives about an hour before kickoff. The type and confirmed fields tell you which one you're looking at.
Common mistakes
- Counting only
goalevents. Penalties arepenalty_scored. - Counting shoot-out kicks as match goals. Skip events with
period: "penalties". - Assuming every stat is there. Check that a key exists first.
- Not handling
nullinlivein the first moments of a match. - Polling faster than your plan's refresh. You get the same data and spend more requests.
- Ignoring
429andRetry-After. - Treating
409as an error. It means the match isn't live; stop or switch endpoints. - Polling from the browser. That exposes your key. Poll from your server and pass the results on.
Related
# Live matches
Find the matches in play, then follow their score, events and stats with a steady polling loop.
Following a live match takes two steps: find out which matches are in play, then poll the live endpoints for the ones you care about. There are no webhooks or streaming connections, so your code asks at a steady pace and stops at full time.
Every example sends your key in the `X-Api-Key` header. Keep that key on your server.
## Which matches are live
Ask [`/matches`](/docs/reference/matches/list) for `status=live`. On a busy day there can be more than the default 20, so raise `per_page`:
```bash
curl "https://api.soccer-data.co/v1/matches?status=live&per_page=100" \
-H "X-Api-Key: YOUR_API_KEY"
```
Add `competition_id` or `team_id` to watch only the games you need. Each live match carries a `live` object with the clock:
```json
{
"id": "match_0i2m68v7oaxa",
"competition_id": "competition_3pcc7lp44661",
"status": "live",
"utc_date": "2026-10-09T19:00:00.000Z",
"home_team": { "id": "team_7p28lyz7ql3i", "name": "West Ham United" },
"away_team": { "id": "team_v3nsjaq0vsxs", "name": "Queens Park Rangers" },
"score": { "home": 0, "away": 1 },
"live": {
"match_status": "first_half",
"elapsed_minutes": 29,
"period": "first_half",
"updated_at": "2026-10-09T19:29:14.093Z"
},
"live_odds_available": true
}
```
`live` is `null` for any match that isn't in play. In the first moments after kickoff, the fields inside it can also be `null` until the clock first updates, so check before you display them.
Refresh this list every minute or two to pick up new kickoffs and final whistles.
## The live endpoints
| Endpoint | What it gives you |
|---|---|
| [`/matches/{match_id}/live-stats`](/docs/reference/matches/live-stats) | Score, clock and running team stats. |
| [`/matches/{match_id}/live-timeline`](/docs/reference/matches/live-timeline) | Goals, cards and substitutions so far. Filter with `event_type`, `period` or `team_id`. |
| [`/matches/{match_id}/live-player-stats`](/docs/reference/matches/live-player-stats) | Each player's numbers so far. Filter with `player_ids` (comma-separated). |
| [`/matches/{match_id}/odds/live`](/docs/reference/odds/live) | In-play prices, when `live_odds_available` is `true`. Needs a paid plan. |
### Score and stats
```bash
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-stats" \
-H "X-Api-Key: YOUR_API_KEY"
```
```json
{
"data": {
"match_id": "match_0i2m68v7oaxa",
"meta": {
"match_status": "first_half",
"elapsed_minutes": 29,
"home_goals": 0,
"away_goals": 1,
"period": "first_half"
},
"stats": {
"ball_possession": { "all": { "home": 55, "away": 45 } },
"total_shots": { "all": { "home": 1, "away": 2 } },
"shots_on_target": { "all": { "home": 0, "away": 1 } },
"expected_goals": { "all": { "home": 0.06, "away": 0.85 } }
}
}
}
```
Which stats appear depends on the match. Check that a key exists before you read it.
### Events
```bash
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-timeline" \
-H "X-Api-Key: YOUR_API_KEY"
```
```json
{
"data": {
"match_id": "match_0i2m68v7oaxa",
"coverage": "full",
"events": [
{
"sequence": 1,
"minute": 17,
"extra_time": 0,
"period": "first_half",
"type": "yellow_card",
"team": { "id": "team_7p28lyz7ql3i", "name": "West Ham United" },
"player": { "id": "player_hjikl8w59an8", "name": "Arne Engels" }
},
{
"sequence": 2,
"minute": 18,
"extra_time": 0,
"period": "first_half",
"type": "penalty_scored",
"team": { "id": "team_v3nsjaq0vsxs", "name": "Queens Park Rangers" },
"player": { "id": "player_zqov38ls8d5o", "name": "Nicolas Madsen" }
}
]
},
"meta": {
"total": 6,
"coverage": "full",
"last_updated": "2026-10-09T20:30:03.372Z",
"match_status": "live"
}
}
```
A few things to watch for:
- **Penalties are their own event type.** A goal from the spot is `penalty_scored`, not `goal`. To get every goal, ask for `event_type=goal,penalty_scored`.
- **Shoot-outs use the same type.** Kicks in a penalty shoot-out are also `penalty_scored`, but with `period` set to `penalties`. Leave them out when you count goals in the match.
- **Stoppage time is split in two.** A goal in the 45+3rd minute has `minute: 45` and `extra_time: 3`.
### Player stats
```bash
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/live-player-stats?player_ids=player_5lxcg0mhwqx3" \
-H "X-Api-Key: YOUR_API_KEY"
```
```json
{
"data": [
{
"player_id": "player_5lxcg0mhwqx3",
"player_name": "Ronnie Edwards",
"team_id": "team_v3nsjaq0vsxs",
"position": "M",
"rating": 6.84,
"started": true,
"minutes_played": 25,
"passing": { "total_passes": 7, "accurate_passes": 7, "key_passes": 0 },
"shooting": { "goals": 0, "total_shots": 0, "expected_assists": 0.01 }
}
]
}
```
Each entry also has a `general` group with touches, fouls and cards.
## How often to poll
Live answers are cached on our side, and how long depends on your plan:
| Plan | Live data normally refreshed every |
|---|---|
| Free | 5 minutes |
| Pro and Business | 15 seconds |
Polling faster than your plan's refresh returns the same answer again, and each call still counts as a request. The `X-Cache` header tells you whether an answer came from the cache (`HIT`) or was fetched fresh (`MISS`). If a fresh answer can't be fetched, you may get an older copy marked `STALE`, with its age in `X-Cache-Age`: see [When an answer is stale](/docs/concepts/rate-limits#when-an-answer-is-stale). Polling every 15 to 30 seconds is plenty on a paid plan.
Plan your budget before you build. On Pro, polling one endpoint every 15 seconds through a two-hour match uses about 480 requests; polling every 30 seconds halves that. On Free, one endpoint every 5 minutes uses about 24. See [Rate limits](/docs/concepts/rate-limits) for your plan's numbers.
To keep the count down:
- Poll only the matches someone is actually looking at.
- Choose the endpoint you need. If you only show the score, `live-stats` alone is enough.
- When you get a `429`, wait the number of seconds in the `Retry-After` header before trying again.
## Before kickoff and after full time
The live endpoints only answer while a match is in play.
| When | `live-stats` | `live-timeline` and `live-player-stats` | `odds/live` |
|---|---|---|---|
| Before kickoff | `409` | `409` | `404` |
| In play | `200` | `200` | `200` when prices exist |
| After full time | `200` with `match_status: "finished"` for up to 5 minutes, then `409` | `409` | `404` |
The `409` has the code `CONFLICT` and the message "Match is not live". Treat it as a signal to switch endpoints, not as a failure.
Once the match is over, use the full-match endpoints instead: [`/stats`](/docs/reference/matches/stats), [`/player-stats`](/docs/reference/matches/player-stats) and [`/timeline`](/docs/reference/matches/timeline). These work the other way round: while the match is still live they return `409`, with the code `MATCH_IS_LIVE` on `/stats`.
### When to stop
Stop polling a match when either of these happens:
- `meta.match_status` in `live-stats` is `finished`.
- A live endpoint returns `409` after the match has started.
## A simple polling loop
```javascript
const headers = { "X-Api-Key": process.env.SOCCER_DATA_KEY };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function follow(matchId) {
while (true) {
const res = await fetch(`https://api.soccer-data.co/v1/matches/${matchId}/live-stats`, { headers });
if (res.status === 409) return; // not live (any more)
if (res.status === 429 || res.status === 503) {
await sleep(Number(res.headers.get("Retry-After") ?? 30) * 1000);
continue;
}
if (!res.ok) throw new Error(`Live stats failed with ${res.status}`);
const { data } = await res.json();
console.log(`${data.meta.elapsed_minutes}' ${data.meta.home_goals}-${data.meta.away_goals}`);
if (data.meta.match_status === "finished") return;
await sleep(30_000);
}
}
await follow("match_0i2m68v7oaxa");
```
## Lineups
You don't have to wait for kickoff for lineups. [`/matches/{match_id}/lineups`](/docs/reference/matches/lineups) has a predicted lineup before the match, and the official team sheet usually arrives about an hour before kickoff. The `type` and `confirmed` fields tell you which one you're looking at.
## Common mistakes
- **Counting only `goal` events.** Penalties are `penalty_scored`.
- **Counting shoot-out kicks as match goals.** Skip events with `period: "penalties"`.
- **Assuming every stat is there.** Check that a key exists first.
- **Not handling `null` in `live`** in the first moments of a match.
- **Polling faster than your plan's refresh.** You get the same data and spend more requests.
- **Ignoring `429` and `Retry-After`.**
- **Treating `409` as an error.** It means the match isn't live; stop or switch endpoints.
- **Polling from the browser.** That exposes your key. Poll from your server and pass the results on.
## Related
- [Fixtures and results](/docs/guides/fixtures-and-results)
- [Player and team stats](/docs/guides/player-and-team-stats)
- [Odds](/docs/guides/odds)
- [Rate limits](/docs/concepts/rate-limits)