Football API Documentation
Complete REST API for football data including fixtures, teams, players, standings, and comprehensive statistics. Build powerful football applications with real-time data.
Basics
Base URL
All requests are made to:
https://api.goal-api.com/v1Authentication
Send your API key as a Bearer token. Requests without a valid key return401.
curl "https://api.goal-api.com/v1/leagues" \
-H "Authorization: Bearer YOUR_API_KEY"Get your key from the dashboard and keep it secret — treat it like a password.
Response format
Every response is JSON with a success flag and a data payload; list endpoints add pagination.
{
"success": true,
"data": { ... },
"pagination": { "total": 100, "limit": 50, "offset": 0, "hasMore": true }
}API Resources
The GOAL API provides access to 12 main resource categories with comprehensive football data.
Webhooks
We POST match events to your URL — no connection to hold open
8 endpoints
Countries
Access country data and associated leagues
3 endpoints
Leagues
Get league information, standings, and fixtures
7 endpoints
Teams
Team data, players, fixtures, and statistics
7 endpoints
Fixtures
Match schedules, live scores, and match details
13 endpoints
Standings
League tables and team rankings
6 endpoints
Players
Player profiles, statistics, and comparisons
6 endpoints
Coaches
Coach information and team assignments
5 endpoints
Head-to-Head
Historical matchups between teams
3 endpoints
Results
Completed match results and scores
8 endpoints
Videos
Match highlights and video content
5 endpoints
News
Football news articles, newest first
5 endpoints
Interactive API Playground
Every endpoint below is runnable live. Paste a key (or sign in to use your own), then hit the green Try it button on any endpoint to send a real request.
Countries
Access country data and retrieve leagues associated with specific countries.
Leagues
Get league information, standings, fixtures, teams, and top scorers for any football league.
Teams
Access team data, squad information, fixtures, results, and detailed statistics.
Fixtures
Get match schedules, live scores, lineups, events, statistics, and detailed match information.
Kickoff times are UTC. Use kickoffUtc. Every fixture carries three time fields: matchDate ("2026-08-22"), matchTime ("22:00") and kickoffUtc ("2026-08-22T22:00:00.000Z"). All three describe the same instant in UTC, and UTC does not observe daylight saving, so a kickoff never shifts by an hour between summer and winter. kickoffUtc is an ISO-8601 timestamp and is the field to build on — the other two are strings kept for compatibility, and a string carries no offset for a client library to read. Convert to your users' local zone yourself; no endpoint takes a timezone parameter, because returning a wall clock without an offset is the ambiguity these fields exist to remove. Before 20 August 2026 these fields carried Central European time (Europe/Berlin) and observed European DST. If you stored kickoffs from an earlier response, they are one hour ahead of the current values in winter and two in summer.
Standings
Access league tables, team rankings, home/away standings, and standings grouped by zones.
Players
Search players, get player profiles, statistics, compare players, and view top performers.
Coaches
Get coach information, search coaches, and view coaches by team or country.
Head-to-Head
Get historical matchups and statistics between two teams.
Results
Get completed match results, today's and yesterday's results, and high-scoring matches.
Videos
Access match highlights and video content for matches and leagues.
News
Football news articles, newest first. Filter by league, team, match or publication date.
Two things worth knowing before you filter. Sort and filter on publishedAt, which is UTC. An article that the source backfills arrives late but belongs where it was published, so ordering by anything else makes the feed reshuffle itself. And teamId, leagueId and matchId are in the same id space as /teams, /leagues and /fixtures, so you can take an id straight from an article to those endpoints — every teamId in the current data resolves. It is not guaranteed, though: an article can reference a competition outside our fixture coverage, which is why teamName and leagueName travel with it. Any of them may be null. Articles are retained for 30 days.
WebSocket (Live Data)
Subscribe to a match and receive live score, event, and statistics updates over a persistent WebSocket connection, instead of polling the REST API.
wss://api.goal-api.com/wsLive data access and the number of matches you can subscribe to at once depend on your plan. Check pricing for details.
Note: Browsers cannot set custom headers on a WebSocket handshake, so Authorization headers don't work here the way they do for REST calls. Use a short-lived connection token instead (Step 1 below) — it keeps your real API key out of the WebSocket URL entirely.
1. Get a connection token
From your backend or app startup code, exchange your API key for a short-lived, single-use connection token (valid for 60 seconds, and consumed the moment it's used). This is a normal authenticated HTTP request, so your API key is sent as a header exactly like every other endpoint on this page.
/v1/ws/tokenconst res = await fetch("https://api.goal-api.com/v1/ws/token", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` }
});
const { data } = await res.json();
// data.token, data.expiresIn (seconds)2. Connect and authenticate the socket
Open the WebSocket with the token as a query parameter, then send it again as your first message to complete authentication.
const ws = new WebSocket(`wss://api.goal-api.com/ws?wsToken=${data.token}`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "auth", token: data.token }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log(message.type, message.data);
};Server-side clients (Node.js, Python, Go, etc.) that can set custom headers may instead send Authorization: Bearer YOUR_API_KEY directly on the handshake and skip Step 1, then authenticate the socket with {"type":"auth","apiKey":"YOUR_API_KEY"}.
3. Subscribe to a match
Once you receive auth_success, subscribe to any fixture by its match ID to start receiving updates for it.
ws.send(JSON.stringify({
type: "subscribe",
resource: "match",
matchId: "12345"
}));4. Receive live updates
While subscribed, you'll receive a match_update message whenever the match state changes.
{
"type": "match_update",
"data": {
"match_id": "781091",
"match_status": "45+2",
"clock": {
"minute": "45+2",
"elapsed": 45,
"extra": 2,
"period": "FIRST_HALF",
"updatedAt": "2026-09-11T18:17:40.512Z"
},
"match_hometeam_score": "2",
"match_awayteam_score": "1",
"match_live": "1",
"goalscorer": [ ... ],
"cards": [ ... ]
},
"timestamp": 1783040229469
}The match clock. match_status is the raw status string and doubles as the clock: "47", "45+" (first-half stoppage, length not given), "90+3", "Half Time", "Finished". clock is the same string parsed: elapsed is the regulation minute, extra the stated stoppage (null for a bare "+" with no number), period one of NOT_STARTED, FIRST_HALF, HALF_TIME, SECOND_HALF, EXTRA_TIME, PENALTIES, FINISHED, and updatedAt when we read it, which is the freshness to apply. The same four values are on every REST fixture as matchMinute, matchElapsed, matchExtra, matchPeriod and clockUpdatedAt. Ignore match_live: it is a legacy flag and can stay "1" on matches that finished hours ago.
| Message type | Direction | Description |
|---|---|---|
| auth | Client → Server | First message on every connection. Send { token } or { apiKey }. |
| auth_success | Server → Client | Authentication succeeded, including your plan's feature flags and limits. |
| subscribe / unsubscribe | Client → Server | Add or remove a match subscription: { resource: "match", matchId }. |
| subscribe_response / unsubscribe_response | Server → Client | Result of a subscribe/unsubscribe request. |
| get_subscriptions | Client → Server | List your currently subscribed match IDs. |
| status | Client → Server | Get your connection's plan, limits, and subscription count. |
| match_update | Server → Client | Pushed whenever a subscribed match's score, events, or stats change. |
| ping / pong | Client ↔ Server | Optional application-level keepalive. |
| error | Server → Client | Sent whenever anything goes wrong — including authentication failures. See the error shape below. |
Error messages
Every error — including a failed auth, a rejected subscription, or a malformed message — arrives as a single error-typed message (or, for a failed subscribe/unsubscribe, as the matching *_response with success: false). Check error.category to branch behavior (e.g. re-authenticate on "authentication", back off on "rate_limit") instead of matching on the message text.
{
"type": "error",
"success": false,
"error": {
"code": "AUTH_FAILED",
"message": "Invalid or expired token",
"category": "authentication"
},
"timestamp": 1783040229469
}| Plan | Concurrent match subscriptions |
|---|---|
| FREE | 0 |
| BASIC | 5 |
| PRO | 20 |
| ENTERPRISE | 1000 |
Webhooks
The counterpart to the WebSocket API. Instead of holding a connection open, you register a URL and we POST to it when a match event happens — a goal, a kick-off, a full-time whistle. This suits serverless functions and backends that cannot keep a socket alive. Manage your endpoints in the dashboard.
Your API key does not work on these endpoints. The /auth/webhook-endpoints routes are account management, not data, so they authenticate with the short-lived session token the dashboard holds — not your gapi_… key. Both travel as Authorization: Bearer …, so the mistake is easy to make; sending an API key returns 401 API_KEY_NOT_ACCEPTED, which means wrong credential type, not wrong plan and not an invalid key. Register and edit endpoints in the dashboard. The POST we send to your URL is signed with the endpoint's own secret and is unrelated to either token.
Events
match.startedA match has kicked off.match.finishedA match has ended (including after extra time or penalties).goal.scoredThe score went up. Fires once per goal.score.changedThe score changed WITHOUT going up — a correction. Not a goal.match.status_changedAny match status transition.Filtering by competition
By default an endpoint receives events from every competition we cover. Set leagueIds to receive only the ones you follow. Filtered-out events are never queued, so they never reach your endpoint and never count against your plan.
# Only these competitions. Ids come from GET /leagues
# (either `id` or `apiId` is accepted), or from
# GET /auth/webhook-endpoints/leagues.
curl -X PATCH https://api.goal-api.com/v1/auth/webhook-endpoints/wh_123 \
-H "Authorization: Bearer $GOAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "leagueIds": ["152", "302", "207"] }'
# An empty array turns the filter off again — every competition, the default.
-d '{ "leagueIds": [] }'Managing endpoints
These are normal authenticated REST calls — your API key as a Bearer token, exactly like every other endpoint on this page.
Verifying the signature
This is not optional. Your endpoint URL is not a secret — it appears in logs and proxies. If you accept any POST that reaches it, anyone who learns the URL can send you fake events. Every delivery carries an X-Goal-Signature header; verify it, and reject anything that does not match.
Node.js / Express// Node.js / Express
import crypto from "crypto";
app.post("/webhooks/goal-api",
express.raw({ type: "application/json" }), // the RAW bytes — a re-serialised
(req, res) => { // body will not match the signature
const header = req.get("X-Goal-Signature"); // t=1736899200,v1=abc123...
const [t, v1] = header.split(",").map(p => p.split("=")[1]);
const expected = crypto
.createHmac("sha256", process.env.GOAL_WEBHOOK_SECRET)
.update(t + "." + req.body)
.digest("hex");
// Constant-time compare: a plain === leaks the correct signature one byte at a
// time to anyone patient enough to measure how long the comparison took.
const valid = crypto.timingSafeEqual(
Buffer.from(v1, "hex"),
Buffer.from(expected, "hex")
);
if (!valid) return res.sendStatus(401);
// Reject anything older than five minutes, or a captured delivery can be replayed.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401);
const event = JSON.parse(req.body);
console.log(event.event, event.data); // "goal.scored", { homeTeam, score, ... }
// Answer 2xx fast. We retry on anything else (1m, 5m, 25m, 2h, 10h), and we time
// out after 8s — so do the work after responding, not before.
res.sendStatus(200);
}
);Delivery & retries
- Respond 2xx quickly. We time out after 8 seconds, so do your work after responding, not before.
- A non-2xx response is retried with backoff:
1m, 5m, 25m, 2h, 10h. - An endpoint that fails 20 times in a row is disabled automatically. Fix it and re-enable it from the dashboard.
- Every attempt is logged — status code, duration, error — on the endpoint's page in the dashboard.
Deliveries and your plan quota
A webhook delivery is a request we make on your behalf, and it is metered like one: each delivery attempt counts against the same allowance as your API calls. There is no separate webhook budget. Three consequences worth knowing before you rely on it:
- A retry costs a request each time. An endpoint returning
500through all five attempts spends five. - When your allowance runs out, deliveries are dropped, not queued. A goal notification arriving a day late is worse than one that never arrives, so we do not hold them — the event is gone.
- That makes the competition filter the difference between an allowance spent on matches you follow and one spent on the worldwide feed.
GET /auth/webhook-endpoints/:id/stats breaks that spend down by competition and by event, including how much went on retries and how many events were dropped. The same breakdown is on the endpoint's card in the dashboard, where each row is a checkbox that writes your filter.
Testing locally
Deliveries are sent from our servers, so they cannot reach localhost or a private address — your machine is not on the public internet, and those URLs are rejected. Expose your local endpoint with a tunnel and register the public URL it gives you:
# ngrok
ngrok http 3000
# -> https://abc123.ngrok-free.app (register this as your webhook URL)
# or cloudflared
cloudflared tunnel --url http://localhost:3000Then hit Send test next to your endpoint in the dashboard to fire a signed sample event immediately — no need to wait for a live match.
Rate Limits
API rate limits are enforced to ensure fair usage and optimal performance for all users. Your rate limit depends on your subscription plan.
How Rate Limiting Works
Rate limits are applied per API key and reset based on your plan's billing cycle. When you exceed your limit, the API will return a 429 Too Many Requests status code.
Per API Key
Each API key has its own independent rate limit quota
Every Request Counts
Every API request you make counts toward your plan's quota
Response Headers
Every API response includes headers to help you track your usage:
X-RateLimit-LimitMaximum requests allowedX-RateLimit-RemainingRequests remaining in current periodX-RateLimit-ResetUnix timestamp when limit resetsX-RateLimit-TypeThe window the three headers above describe — DAILY on every current plan. It does not mean daily is the only limit; see below.Short-window ceilings
Your plan quota is the limit you should design around, and it is the one the headers above report. Underneath it sit three flood ceilings that exist only to stop runaway clients. They are set well above any realistic usage — a normal integration, including a bulk backfill, will never see them — but they are real, so they are documented rather than discovered:
130 / secondPer key. Burst protection; returns BURST_LIMIT_EXCEEDED.6,000 / minutePer key — 100 req/s sustained.60 / minuteRequests with no API key at all, per IP.All three are per-minute or per-second windows, so they clear on their own within a minute — a 429 from one of these is not your daily quota being spent. A quota 429 carries QUOTA_EXCEEDED and a Retry-After pointing at the next reset.
Best Practices
- Monitor the
X-RateLimit-Remainingheader to track your usage - Implement exponential backoff when you receive a 429 response
- Cache responses on your end to reduce API calls
- Use pagination parameters to fetch data in smaller chunks
Need higher limits?
Check our pricing plans for higher rate limits and additional features.
Error Handling
The API uses standard HTTP status codes to indicate success or failure.
| Status Code | Description |
|---|---|
| 200 | Successful request |
| 204 | No content found |
| 400 | Bad request (invalid parameters) |
| 401 | Unauthorized (missing or invalid API key) |
| 429 | Too many requests (rate limit or plan quota exceeded) |
| 500 | Server error |
MCP (AI assistants)
A hosted Model Context Protocol server lets Claude, or any MCP client, query this API directly. Ask it “how did Arsenal do last week?” and it answers from the same data the endpoints above serve. Nothing to install — add one entry to your client config with your existing API key:
{
"mcpServers": {
"goal-api": {
"url": "https://mcp.goal-api.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Fourteen read-only tools cover fixtures, results, live matches, tables, squads, head-to-head records and top scorers. Competitions and teams are given by name — there are no ids to look up. Every tool call counts as one request against your plan, the same as a REST call.