Single source of truth for all five SDKs. Derived from the live service, not from prose:
- Gateway mounts (
services/api-gateway/src/app.js:126-129,routes/football.js) - Route tables (
services/football-service/src/routes/*.js) - Parameter validators (
services/football-service/src/middleware/validators/*.js) - Error envelope (
services/api-gateway/src/errors/sendErrorResponse.js) - Rate-limit headers (
services/api-gateway/src/middleware/rateLimiter.js:350-379) - WebSocket protocol (
services/websocket-service/src/handlers/messageRouter.js,subscriptionHandler.js) - Webhook signing (
services/worker-service/src/services/webhookDeliveryService.js:22-67)
| Base URL | https://api.goal-api.com/v1 |
| Auth | Authorization: Bearer <API_KEY> (the only accepted form — no X-API-Key) |
| Content type | application/json |
| Methods used by the data API | GET only |
Collection endpoints:
{
"success": true,
"data": [ ... ],
"pagination": { "total": 1234, "limit": 50, "offset": 0, "hasMore": true },
"source": "cache" | "database"
}Single-resource endpoints return { "success": true, "data": { ... } }. The betting
endpoints additionally return top-level fixtureId, matchApiId and count.
There are two shapes, confirmed by integration/sweep.py against production. An SDK
has to read both.
Gateway errors (auth, routing, rate limits) — services/api-gateway/src/errors/sendErrorResponse.js:
{
"success": false,
"message": "Route not found",
"code": "ROUTE_NOT_FOUND",
"category": "not_found",
"details": { "path": "/api/v1/no/such/route" },
"correlationId": "gw-1785382415462-0dne7w5",
"timestamp": "2026-07-30T03:33:35.463Z"
}Football-service errors — the great majority of what a client hits:
{
"success": false,
"error": "Fixture not found",
"code": "FIXTURE_NOT_FOUND"
}Validation errors from that service carry express-validator's array in details:
{
"success": false,
"error": "Validation failed",
"code": "VALIDATION_ERROR",
"details": [
{ "type": "field", "value": "not-a-stat", "msg": "Invalid stat parameter",
"path": "stat", "location": "params" }
]
}So, portably:
| Field | Always present | Notes |
|---|---|---|
success: false |
yes | |
code |
yes | |
message |
gateway only | Human text. Service errors put it in error instead. |
error |
service only | Read message ?? error. |
category |
gateway only | |
correlationId |
gateway only | Do not rely on it for service errors. |
timestamp |
gateway only | |
details |
sometimes | Object on gateway errors, array on service validation errors. |
Every SDK normalises this: one message field regardless of shape, and a nullable
correlationId.
Codes the SDKs map to typed errors:
| Status | Code | SDK error |
|---|---|---|
| 400 / 422 | VALIDATION_ERROR |
ValidationError |
| 401 | AUTH_FAILED, INVALID_API_KEY, INVALID_WS_TOKEN, WS_TOKEN_ALREADY_USED |
AuthenticationError |
| 402 | PLAN_UPGRADE_REQUIRED |
PlanUpgradeRequiredError |
| 403 | ACCESS_DENIED |
PermissionError |
| 404 | ROUTE_NOT_FOUND, plus per-resource codes: FIXTURE_NOT_FOUND, STANDINGS_NOT_FOUND, H2H_NOT_FOUND, H2H_STATS_NOT_FOUND, … |
NotFoundError |
| 409 | CONFLICT, DUPLICATE_RECORD |
ConflictError |
| 429 | RATE_LIMIT_EXCEEDED, BURST_LIMIT_EXCEEDED |
RateLimitError |
| 503 | SERVICE_UNAVAILABLE |
ServiceUnavailableError |
| 5xx | INTERNAL_ERROR, DATABASE_ERROR |
ServerError |
Set on every authenticated response: X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset (unix seconds), X-RateLimit-Type (DAILY / MONTHLY), plus
Retry-After (seconds) on 429.
| Param | Type | Notes |
|---|---|---|
limit |
int | Per-endpoint max — 50/100/200/500, see table below |
offset |
int | >= 0 |
from, to |
YYYY-MM-DD |
to must be >= from |
season |
YYYY-YYYY or YYYY/YY |
Must be consecutive years, e.g. 2025-2026 |
isActive |
bool | true / false |
live |
bool | true / false |
search |
string | 2–100 chars, [a-zA-Z0-9 \-'.] only |
q |
string | Required on /search endpoints, same charset |
status |
enum | SCHEDULED LIVE FINISHED HALF_TIME AFTER_ET AFTER_PEN POSTPONED CANCELLED AWARDED ABANDONED SUSPENDED |
type |
enum | Goalkeepers Defenders Midfielders Forwards |
half |
enum | full 1half 2half |
stage |
string | 1–100 chars |
country |
string | 2–100 chars, letters/spaces/hyphens |
stat |
enum | goals assists yellowCards redCards rating matchPlayed minutes saves tackles shotsTotal keyPasses passes interceptions duelsWon dribbleSucc |
ids |
csv | 2–5 player ids, /players/compare only |
includePlayers |
bool | /teams/{id} only |
client.<group>.<method>() in every language (naming adapted per language convention:
camelCase for JS/Dart/PHP, snake_case for Python, PascalCase for Go).
These five endpoints do not use the
{success, data}envelope. They return bare objects, they paginate withpage/limitrather thanlimit/offset, and their 404 body is{error, code}with nomessage. Verified against the live API andservices/football-service/src/routes/public.js. The SDKs return their bodies as-is rather than pretending they are wrapped — do not reach for.dataon these.
| Method | Endpoint | Params | Response |
|---|---|---|---|
status.get() |
GET /public/status |
— | {status, updatedAt, measurement, components[]} |
status.coverage() |
GET /public/coverage |
— | {leagues, countries, teams, players, fixtures, …} |
status.coverageLeagues(p) |
GET /public/coverage/leagues |
q country page limit |
{leagues[], total, page, limit, pages} |
status.coverageCountries() |
GET /public/coverage/countries |
— | {countries[], total} |
status.coverageLeague(id) |
GET /public/coverage/leagues/{id} |
— bare league object | 404 → {error, code} |
Rate limited by IP at 60/min, with the draft-standard RateLimit-* headers rather than
X-RateLimit-* — so these calls leave the SDKs' rate-limit snapshot untouched.
| Method | Endpoint | Params |
|---|---|---|
countries.list(p) |
GET /countries |
isActive search limit≤500 offset |
countries.get(id) |
GET /countries/{id} |
— |
countries.leagues(id, p) |
GET /countries/{id}/leagues |
season isActive limit≤100 offset |
| Method | Endpoint | Params |
|---|---|---|
leagues.list(p) |
GET /leagues |
countryId season search isActive limit≤100 offset |
leagues.get(id) |
GET /leagues/{id} |
— |
leagues.teams(id, p) |
GET /leagues/{id}/teams |
season limit≤100 offset |
leagues.standings(id, p) |
GET /leagues/{id}/standings |
stage |
leagues.fixtures(id, p) |
GET /leagues/{id}/fixtures |
from to status limit≤100 offset |
leagues.topScorers(id, p) |
GET /leagues/{id}/top-scorers |
limit≤100 |
leagues.results(id, p) |
GET /leagues/{id}/results |
from to limit≤100 offset |
| Method | Endpoint | Params |
|---|---|---|
teams.list(p) |
GET /teams |
leagueId country search isActive limit≤100 offset |
teams.get(id, p) |
GET /teams/{id} |
includePlayers |
teams.players(id, p) |
GET /teams/{id}/players |
type limit≤100 offset |
teams.fixtures(id, p) |
GET /teams/{id}/fixtures |
status from to limit≤100 offset |
teams.results(id, p) |
GET /teams/{id}/results |
from to limit≤100 offset |
teams.statistics(id, p) |
GET /teams/{id}/statistics |
season |
teams.upcoming(id, p) |
GET /teams/{id}/upcoming |
limit≤50 |
| Method | Endpoint | Params |
|---|---|---|
fixtures.list(p) |
GET /fixtures |
from to leagueId teamId status live limit≤100 offset |
fixtures.live(p) |
GET /fixtures/live |
leagueId |
fixtures.byDate(date, p) |
GET /fixtures/date/{date} |
leagueId teamId limit≤100 offset |
fixtures.get(id) |
GET /fixtures/{id} |
— |
fixtures.events(id) |
GET /fixtures/{id}/events |
— |
fixtures.lineups(id) |
GET /fixtures/{id}/lineups |
— |
fixtures.statistics(id, p) |
GET /fixtures/{id}/statistics |
half |
fixtures.cards(id) |
GET /fixtures/{id}/cards |
— |
fixtures.substitutions(id) |
GET /fixtures/{id}/substitutions |
— |
fixtures.odds(id) |
GET /fixtures/{id}/odds |
— |
fixtures.predictions(id) |
GET /fixtures/{id}/predictions |
— |
fixtures.liveOdds(id) |
GET /fixtures/{id}/live-odds |
— |
fixtures.commentary(id) |
GET /fixtures/{id}/commentary |
— |
The four betting sub-resources accept either a GOAL fixture id or the provider's
matchApiId.
Every fixture row carries the clock as five fields (since 2026-09-11; rows last synced
before that have them as null):
| Field | Meaning |
|---|---|
matchMinute |
The clock as a display string: "47", "45+" (first-half stoppage, length not stated), "90+3", "Half Time", "Finished". "" before kick-off. |
matchElapsed |
The regulation minute as an integer. 45 during first-half stoppage and at half time, 90 during second-half stoppage. null when not in play. |
matchExtra |
Stated stoppage minutes past matchElapsed: 3 for "90+3". null for a bare "90+" and outside stoppage. |
matchPeriod |
NOT_STARTED FIRST_HALF HALF_TIME SECOND_HALF EXTRA_TIME PENALTIES FINISHED, or null when unknown. Inferred from the minute: 1–45 and 45+ are FIRST_HALF, 46–90 and 90+ SECOND_HALF, above 90 EXTRA_TIME. |
clockUpdatedAt |
When the clock was last refreshed. In-play fixtures refresh every few seconds; a LIVE row with a clockUpdatedAt older than a minute means the feed has stalled. |
matchLive is a legacy flag and is not a liveness
signal: it stays "1" on matches that finished hours ago. Use matchStatus and
matchPeriod. homeTeamHalftimeScore / awayTeamHalftimeScore are final only once
matchPeriod is past HALF_TIME; before that they are provisional (empty or a running "0").
fixtures.statistics(id, { half }) returns match.fullTime, match.firstHalf and
match.secondHalf. First-half rows exist for fixtures synced since 2026-09-11; the second
half only where available (rarely), and is never derived as full minus first.
hasStatistics describes the block(s) returned: with half set, that half alone.
| Method | Endpoint | Params |
|---|---|---|
standings.get(leagueId, p) |
GET /standings/{leagueId} |
stage |
standings.team(leagueId, teamId) |
GET /standings/{leagueId}/team/{teamId} |
— |
standings.home(leagueId, p) |
GET /standings/{leagueId}/home |
stage |
standings.away(leagueId, p) |
GET /standings/{leagueId}/away |
stage |
standings.form(leagueId, p) |
GET /standings/{leagueId}/form |
stage |
standings.zones(leagueId, p) |
GET /standings/{leagueId}/zones |
stage |
/home and /away filter on the provider's homeLeaguePosition, which is empty for many
leagues. Those return 404 STANDINGS_NOT_FOUND even when the base table has rows — handle
404 here as "no split available", not as a bad league id.
| Method | Endpoint | Params |
|---|---|---|
players.list(p) |
GET /players |
teamId type search limit≤100 offset |
players.search(q, p) |
GET /players/search |
q (required) limit≤100 |
players.compare(ids) |
GET /players/compare |
ids (2–5, csv) |
players.top(stat, p) |
GET /players/top/{stat} |
teamId type limit≤100 |
players.get(id) |
GET /players/{id} |
— |
players.statistics(id, p) |
GET /players/{id}/statistics |
season |
| Method | Endpoint | Params |
|---|---|---|
coaches.list(p) |
GET /coaches |
search country limit≤100 offset |
coaches.search(q, p) |
GET /coaches/search |
q (required) limit≤100 |
coaches.byCountry(country, p) |
GET /coaches/country/{country} |
limit≤100 offset |
coaches.byTeam(teamId) |
GET /coaches/team/{teamId} |
— |
coaches.get(id) |
GET /coaches/{id} |
— |
| Method | Endpoint | Params |
|---|---|---|
h2h.get(t1, t2) |
GET /h2h/{team1Id}/{team2Id} |
— |
h2h.direct(t1, t2, p) |
GET /h2h/{team1Id}/{team2Id}/direct |
limit≤100 |
h2h.stats(t1, t2) |
GET /h2h/{team1Id}/{team2Id}/stats |
— |
team1Id and team2Id must differ (server-enforced).
| Method | Endpoint | Params |
|---|---|---|
results.list(p) |
GET /results |
leagueId teamId from to limit≤500 offset |
results.today() |
GET /results/today |
— |
results.yesterday() |
GET /results/yesterday |
— |
results.stats(p) |
GET /results/stats |
leagueId from to |
results.highScoring(p) |
GET /results/high-scoring |
leagueId from to limit≤100 |
results.byDate(date) |
GET /results/date/{date} |
— |
results.byLeague(leagueId, p) |
GET /results/league/{leagueId} |
from to limit≤500 offset |
results.byTeam(teamId, p) |
GET /results/team/{teamId} |
from to limit≤500 offset |
| Method | Endpoint | Params |
|---|---|---|
videos.list(p) |
GET /videos |
leagueId from to limit≤100 offset |
videos.recent(p) |
GET /videos/recent |
leagueId limit≤100 |
videos.byMatch(matchId) |
GET /videos/match/{matchId} |
— |
videos.byLeague(leagueId, p) |
GET /videos/league/{leagueId} |
from to limit≤100 offset |
videos.byDate(date, p) |
GET /videos/date/{date} |
leagueId limit≤100 offset |
| Method | Endpoint | Params |
|---|---|---|
news.list(p) |
GET /news |
leagueId teamId matchId from to limit≤100 (default 20) offset |
news.byMatch(matchId, p) |
GET /news/match/{matchId} |
from to limit≤100 offset |
news.byTeam(teamId, p) |
GET /news/team/{teamId} |
from to limit≤100 offset |
news.byLeague(leagueId, p) |
GET /news/league/{leagueId} |
from to limit≤100 offset |
news.get(id) |
GET /news/{id} |
— |
from/to filter on publishedAt, and to covers the whole of that day. Sort is
publishedAt descending; retention is 30 days.
teamId/leagueId/matchId are in the same id space as /teams, /leagues and
/fixtures — an id taken from an article can be used against those endpoints directly,
and every teamId in the current data resolves. 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.
news.get accepts our id or the provider's own news key. A single-resource endpoint,
so it returns {success, data: {...}} and 404s rather than returning an empty
object.
| Method | Endpoint | Params |
|---|---|---|
odds.list(p) |
GET /odds |
bookmaker matchId limit≤200 (default 50) offset |
predictions.list(p) |
GET /predictions |
matchId leagueName limit≤200 (default 50) offset |
- URL:
wss://api.goal-api.com/v1/ws - Server-side auth:
Authorization: Bearer <API_KEY>on the handshake. - Browser auth:
POST /v1/ws/tokenwith the API key →{ data: { token, expiresIn } }, then connect towss://api.goal-api.com/v1/ws?wsToken=<token>. Single-use, short TTL.
Client → server:
{"type": "ping"}
{"type": "subscribe", "resource": "match", "matchId": "..."}
{"type": "unsubscribe", "resource": "match", "matchId": "..."}
{"type": "get_subscriptions"}
{"type": "status"}resource only accepts "match". Client messages are capped at 60/minute
(MESSAGE_RATE_LIMIT_EXCEEDED); concurrent subscriptions are capped by plan.
Server → client: auth_success, match_update, pong, status, server_shutdown,
error.
A match_update carries the raw match_status string (which is also the clock:
"47", "45+2", "Half Time") and, beside it, the same string parsed:
"clock": { "minute": "45+2", "elapsed": 45, "extra": 2, "period": "FIRST_HALF", "updatedAt": "2026-09-11T18:17:40.512Z" }period uses the same values as the REST matchPeriod. Ignore match_live, for the
reason given under fixtures.
Delivered as POST with headers X-Goal-Signature, X-Goal-Event, X-Goal-Delivery.
Signature is the Stripe scheme — t=<unix>,v1=<hex> where the HMAC-SHA256 is computed
over "<timestamp>.<raw body>" using the endpoint secret. Verify against the raw
bytes, and reject timestamps outside a tolerance window (default 300s) to stop replays.
Events: match.started, match.finished, goal.scored, score.changed,
match.status_changed.
Retry schedule on the server: 5 attempts at ~1m, 5m, 25m, 2h, 10h.
Webhook management (/auth/webhook-endpoints) is authenticated with a dashboard JWT,
not an API key, so it is intentionally out of scope for these SDKs. Signature
verification is included.