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.

Ready to get started?

Sign up for a free API key and start building in minutes.

Basics

Base URL

All requests are made to:

https://api.goal-api.com/v1

Authentication

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.

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/ws

Live 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.

POST/v1/ws/token
const 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 typeDirectionDescription
authClient → ServerFirst message on every connection. Send { token } or { apiKey }.
auth_successServer → ClientAuthentication succeeded, including your plan's feature flags and limits.
subscribe / unsubscribeClient → ServerAdd or remove a match subscription: { resource: "match", matchId }.
subscribe_response / unsubscribe_responseServer → ClientResult of a subscribe/unsubscribe request.
get_subscriptionsClient → ServerList your currently subscribed match IDs.
statusClient → ServerGet your connection's plan, limits, and subscription count.
match_updateServer → ClientPushed whenever a subscribed match's score, events, or stats change.
ping / pongClient ↔ ServerOptional application-level keepalive.
errorServer → ClientSent 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
}
PlanConcurrent match subscriptions
FREE0
BASIC5
PRO20
ENTERPRISE1000

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 500 through 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:3000

Then 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:

Headers
X-RateLimit-LimitMaximum requests allowed
X-RateLimit-RemainingRequests remaining in current period
X-RateLimit-ResetUnix timestamp when limit resets
X-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-Remaining header 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 CodeDescription
200Successful request
204No content found
400Bad request (invalid parameters)
401Unauthorized (missing or invalid API key)
429Too many requests (rate limit or plan quota exceeded)
500Server 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.

Full MCP documentation →

Football API Documentation — REST & WebSocket Reference | GOAL API