How to Get Live Football Data Into Google Sheets
Most football analysis starts in a spreadsheet, and most of it starts by someone copying a table off a website by hand. That works until you want last week too, and it stops working entirely once you want it to refresh on its own.
This walks through pulling football data straight into Google Sheets. No add-on, no paid connector — Google Sheets ships with everything needed, it is just not the part most people find first.
Why IMPORTDATA will not do it
The obvious approach is the built-in formula:
=IMPORTDATA("https://api.example.com/v1/fixtures")
It fails on any real API, and the reason is worth understanding: IMPORTDATA cannot send headers. No Authorization, no API key. It only fetches genuinely public URLs, and it only parses CSV or TSV — hand it JSON and you get a column of unusable text.
Some APIs let you put the key in the query string to work around this. That is a bad habit: URLs end up in browser history, in server logs, and in the sheet itself, which you will eventually share with someone.
The supported route is Apps Script, which is free, built in, and can set headers.
Setting up
In your sheet: Extensions → Apps Script. You get an editor with an empty myFunction.
First, put the key somewhere that is not the code — Apps Script has a properties store for exactly this, so the key does not travel when you share the sheet or the script:
function saveKey() {
PropertiesService.getScriptProperties()
.setProperty('API_KEY', 'fb_live_your_key');
}
Run saveKey once, then delete the literal key from the file. From here on:
function apiGet(path) {
const key = PropertiesService.getScriptProperties().getProperty('API_KEY');
const response = UrlFetchApp.fetch('https://api.5dollarfootballapi.com/v1' + path, {
headers: { Authorization: 'Bearer ' + key },
muteHttpExceptions: true,
});
const body = JSON.parse(response.getContentText());
if (response.getResponseCode() !== 200) {
throw new Error(body.error ? body.error.message : 'Request failed');
}
return body.data;
}
UrlFetchApp is the part that can do what IMPORTDATA cannot: set an Authorization header. muteHttpExceptions is there so a 401 or 429 comes back as a readable message rather than a stack trace — you want to know which thing went wrong.
Today's fixtures into a sheet
function loadFixtures() {
const fixtures = apiGet('/fixtures');
const rows = [['Kickoff (UTC)', 'League', 'Home', 'Away', 'Status', 'Score']];
fixtures.forEach(function (f) {
rows.push([
f.kickoff_utc,
f.league.name,
f.teams.home.name,
f.teams.away.name,
f.status,
f.goals ? f.goals.home + ' - ' + f.goals.away : '',
]);
});
const sheet = SpreadsheetApp.getActive().getSheetByName('Fixtures')
|| SpreadsheetApp.getActive().insertSheet('Fixtures');
sheet.clear();
sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
}
Two things in there matter more than they look.
Building the whole rows array and writing it in one setValues call, rather than a setValue per cell, is the difference between a script that finishes and one that times out. Each individual write is a round trip to Google's servers; a few hundred of them will exhaust the six-minute execution limit.
And sheet.clear() before writing means a re-run replaces the data instead of leaving yesterday's longer table poking out underneath today's.
A corner table, which is the part spreadsheets are actually good at
League tables ranked by corners rather than points — the sort of thing that is genuinely awkward to find and trivial to work with once it is in a grid:
function loadCornerTable(leagueId, season) {
const standings = apiGet('/standings?league=' + leagueId + '&season=' + season + '&type=corner');
const rows = [['#', 'Team', 'Played', 'Corners for', 'Corners against', 'Avg for']];
standings.table.forEach(function (row) {
rows.push([
row.position,
row.team.name,
row.played,
row.total_for,
row.total_against,
row.average_for,
]);
});
const sheet = SpreadsheetApp.getActive().getSheetByName('Corners')
|| SpreadsheetApp.getActive().insertSheet('Corners');
sheet.clear();
sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
}
Note standings.table rather than standings — the standings endpoint wraps its rows alongside the league and season it resolved, so you can tell which season you actually got when you left the parameter off.
Making it refresh by itself
In the Apps Script editor, the clock icon on the left, then Add Trigger: pick the function, choose a time-driven trigger, set an interval.
Do the arithmetic on your rate limit before choosing that interval. Refreshing every minute is 1,440 calls a day for data that, for finished matches, has not changed since the final whistle. Hourly is plenty for fixtures and tables; save the frequent polling for ?status=live during matches, where it earns its keep.
Common failures
401 — the key is missing or wrong. Run saveKey again; the properties store is per-script, so a copied sheet starts empty.
403 — the request is outside your plan. Free keys reach the top-5 European leagues; ask for a league outside that and you get a 403 that says so rather than an empty table pretending nothing was there.
429 — too fast. Every response carries X-RateLimit-Remaining, and Apps Script can read it via response.getHeaders() if you want the script to back off on its own.
Times out at six minutes — almost always a setValue inside a loop. Collect the rows, write once.
Everything above runs on a free key except the corner table, which needs a paid plan for leagues outside the big five. Full parameter lists are in the fixtures and standings reference.
If you would rather do this in Python than in a spreadsheet, how much a corner line actually moves works the same data from the other direction.