Build a live football scoreboard

8 September 2026 · 7 min read

A scoreboard is the smallest useful thing you can build on football data, and it is the one most tutorials get wrong in a way that will leak your API key. Here is the whole thing, including the part they skip.

What live returns

One endpoint, no parameters:

curl -H "X-RapidAPI-Key: YOUR_KEY" \
  -H "X-RapidAPI-Host: premier-league18.p.rapidapi.com" \
  "https://premier-league18.p.rapidapi.com/matches/live"

When nothing is being played, you get this, and you should design for it first because it is the answer most of the time:

{ "data": [], "meta": { "season": "2026-27" } }

When a match is in play, each entry carries the score and the minute, plus both teams with a crest URL you can use directly in an img tag.

Poll every 30 seconds

Our updater polls the source every 30 seconds while matches are in play. Requesting more often than that cannot give you fresher data, it just spends your quota. The response also carries a cache-control header of 15 seconds, which is the shortest interval at which a repeat request can return anything new.

Thirty seconds is the right number. Sixty is fine for a background widget.

Your key cannot go in the browser

This is the part that gets skipped. If your page calls the API directly with fetch, your key is in the page source, in the network tab, and in the hands of anyone who opens developer tools. Keys get scraped from public sites automatically.

Put a small proxy in front. It is fifteen lines:

// server.js
import express from 'express';

const app = express();
const KEY = process.env.RAPIDAPI_KEY;
const HOST = 'premier-league18.p.rapidapi.com';

app.get('/api/live', async (_req, res) => {
  const upstream = await fetch('https://' + HOST + '/matches/live', {
    headers: { 'X-RapidAPI-Key': KEY, 'X-RapidAPI-Host': HOST },
  });
  // Let the browser cache briefly so a reload does not cost a call.
  res.set('cache-control', 'public, max-age=15');
  res.status(upstream.status).json(await upstream.json());
});

app.use(express.static('public'));
app.listen(3000);

The browser now talks to your server, your server holds the key, and your quota is spent once per 30 seconds no matter how many people have the page open.

The page

<!doctype html>
<ul id="board"></ul>
<script>
const board = document.getElementById('board');

const render = (matches) => {
  if (matches.length === 0) {
    board.innerHTML = '<li>No matches in play</li>';
    return;
  }
  board.innerHTML = matches.map((m) =>
    '<li>' +
      '<img src="' + m.homeTeam.crestUrl + '" width="20" height="20" alt=""> ' +
      m.homeTeam.name + ' ' + m.score.home + ' - ' + m.score.away + ' ' + m.awayTeam.name +
      ' <small>' + m.minute + "'</small>" +
    '</li>'
  ).join('');
};

const tick = async () => {
  try {
    const res = await fetch('/api/live');
    if (!res.ok) return;               // keep the last good board on screen
    render((await res.json()).data);
  } catch { /* offline: leave what is there */ }
};

tick();
setInterval(tick, 30000);
</script>

Three things that will happen in production

The request will fail. Networks drop and upstreams have bad minutes. Notice that the code above leaves the previous scores on screen rather than blanking the board. A scoreboard that flashes empty on one failed poll looks broken even though nothing is wrong.

Nothing will be live. Most of the day, and most of the week, the array is empty. Handle it as a normal state with real copy, not as an error.

A match will end while someone is watching. A finished match drops out of /matches/live on the next poll. If you want it to stay on screen, keep your own list and mark entries as finished when they disappear, rather than replacing the array each time.

Where to go next

Once a match is interesting, /matches/{id} gives you the lineups, the events and a rating per player. That is the subject of the next article.

More

Head-to-head records, and what they are actually good for

They settle ties in three leagues and fill every match preview. Tested as a predictor on 3,042 league matches, this season's form beat them.

Why we serve no table rather than half a season

A league table built from part of a season is not partial data, it is wrong data that looks right. The rule we apply, what it costs us, and why a 404 is the better answer.

How not to burn your free tier

Most quota goes on requests that could not have returned anything new. Read the cache headers you are already being sent, stop polling faster than the data moves, and know which plan the thing you are building actually needs.

Two teams, same points: every league breaks the tie differently

Goal difference decides it in England, Germany and France. In Spain, Italy, Portugal and Turkey the head-to-head record comes first. Sort a table yourself and you have it wrong for four of the twelve.

What's inside a match: lineups, events and ratings

A field-by-field walk through a match detail response: formations, starting elevens, the event timeline, and how to rebuild a goal-by-goal summary from it.

Try it

Every endpoint mentioned here is in the reference, and every listing has a free tier. Start on the front page.