Building with AI? Give your coding tool the whole reference in one file: https://soccer-data.co/llms.txt
SOCCER DATA Docs
Sign in Get API key
// Guides

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, 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

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-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, /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_status in live-stats is finished.
  • A live endpoint returns 409 after 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 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.