Five official SDKs: JavaScript, Python, Go, Dart and PHP
Three quirks in our own API used to be every integrator's problem. Now five first-party clients absorb them — npm, PyPI, pkg.go.dev, pub.dev and Packagist, all shipping 1.0.0.
GOAL API· Engineering

What shipped
Five SDKs, all at 1.0.0, all published today.
| Language | Package | Install | Runtime deps |
|---|---|---|---|
| JavaScript / TypeScript | @goalapi/sdk | npm install @goalapi/sdk |
none |
| Python | goal-api | pip install goal-api |
httpx |
| Go | goal-api-go | go get github.com/goal-api/goal-api-go |
none |
| Dart / Flutter | goal_api | dart pub add goal_api |
http, web_socket_channel, crypto |
| PHP | goal-api/sdk | composer require goal-api/sdk |
none |
Source is on GitHub, one repository each: js, python, go, dart, php.
Why an SDK, when the API is just REST
Most football data APIs hand you a curl snippet and a page of code samples. What you actually integrate against is a community wrapper — and community wrappers go stale about a year after the person who wrote them changes jobs.
These are first-party. They are also the reason we found three things wrong with our own API, because writing a client is the only way to find out what an API is really like to use.
1. /public/* breaks the envelope
Every endpoint returns { success, data, pagination }. Except the five /public/*
endpoints, which return bare objects, and paginate with page/limit instead of
limit/offset.
Every SDK now knows this. status.get() returns the object directly, and the paginator
refuses to walk an endpoint it cannot walk:
const status = await goal.status.get();
status.status; // 'operational' — no .data
status.components; // [{ name, status, uptime }]
2. There are two error shapes
The gateway sends message plus a correlationId. The football service, which serves
most of the endpoints, sends error with no correlation id and an array in details.
Written by different people, at different times, both in production.
You should never have had to branch on that. Every SDK normalises both into one error
type with one message field, then subclasses by what you'd actually do differently:
from goal_api import RateLimitError, NotFoundError, PlanUpgradeRequiredError
3. Error bodies are not always JSON
nginx answers a 502 with HTML. A client that assumes response.json() on any non-2xx
crashes on the parse instead of surfacing the 502 — the failure gets reported as a JSON
error and you spend an hour looking in the wrong place.
The SDKs read the status first and treat the body as best-effort.
None of the three are hypothetical. They were found by a conformance sweep that runs
every endpoint over raw HTTP, currently 79 of 79 passing, and each one is written down
in the ENDPOINTS.md that ships in all five repos.
The same twenty lines, five times
Live matches, printed:
import { GoalApi } from '@goalapi/sdk';
const goal = new GoalApi({ apiKey: process.env.GOAL_API_KEY });
const { data: live } = await goal.fixtures.live();
for (const m of live) {
console.log(`${m.homeTeam?.name} ${m.homeScore}-${m.awayScore} ${m.awayTeam?.name}`);
}
import os
from goal_api import GoalAPI
with GoalAPI(os.environ["GOAL_API_KEY"]) as goal:
for m in goal.fixtures.live()["data"]:
print(m["homeTeam"]["name"], m["homeScore"], "-", m["awayScore"], m["awayTeam"]["name"])
client, err := goalapi.New(os.Getenv("GOAL_API_KEY"))
if err != nil {
log.Fatal(err)
}
page, err := client.Fixtures.Live(context.Background(), nil)
if err != nil {
log.Fatal(err)
}
var live []Fixture
if err := page.Into(&live); err != nil {
log.Fatal(err)
}
final goal = GoalApi(apiKey: apiKey);
final page = await goal.fixtures.live();
for (final m in page['data'] as List) {
print('${m['homeTeam']['name']} ${m['homeScore']}-${m['awayScore']}');
}
goal.close();
$goal = new GoalApi(getenv('GOAL_API_KEY'));
foreach ($goal->fixtures->live()['data'] as $m) {
printf("%s %d-%d %s\n", $m['homeTeam']['name'], $m['homeScore'], $m['awayScore'], $m['awayTeam']['name']);
}
Same resource groups everywhere: countries, leagues, teams, fixtures,
standings, players, coaches, h2h, results, videos, odds, predictions,
status.
What every one of them does for you
Retries. 429s, 5xx and network errors, exponential backoff with full jitter, always
honouring a server-sent Retry-After. Aborted requests are never retried. Two by
default.
Pagination. Walk pages without writing an offset loop:
for await (const team of goal.paginate((p) => goal.teams.list({ leagueId, ...p }))) {
console.log(team.name);
}
Default page size is 100, the ceiling on most endpoints. /results and /countries
take 500.
Typed errors. RateLimitError, ValidationError, NotFoundError,
PlanUpgradeRequiredError. Branch only where you'd behave differently.
The raw envelope. Methods return it rather than unwrapping to an array, so
pagination.hasMore and source — 'cache' or 'database' — stay reachable.
Live WebSocket updates, including the part that is genuinely fiddly by hand. The
socket lives at wss://api.goal-api.com/ws, not /v1/ws: only nginx's
location ^~ /ws carries the Upgrade headers, so /v1/ws answers 200 instead of
upgrading. Then two services authenticate — the gateway authorises the upgrade, and the
websocket service needs an auth frame as the very first message. The SDK derives the
URL, sends the frame, and treats auth_success as the point the connection is usable:
const live = goal.live();
live.on('match_update', ({ data }) => console.log(data));
await live.connect();
live.subscribe(fixtureId);
One caveat we would rather write down than have you discover: subscribe is capped per
plan, and on some plans the cap is 0. auth_success reports maxSubscriptions. If it
comes back zero, the socket connects fine and no match_update will ever arrive.
Released in lockstep
1.4.0 will mean the same surface in all five languages. A version check runs across the
eight places the number lives and fails the build on drift, which is the only way five
independent repos stay in step for longer than a month.
Every repo also carries the same ENDPOINTS.md, derived from the running service and
checked against production rather than written from the docs.
Tested, and honest about it
Each SDK has unit tests that need no network, plus live tests that run when
GOAL_API_KEY is set and skip when it isn't — so the same command works on a laptop and
in CI.
| SDK | Unit | Live |
|---|---|---|
| JavaScript | 26 | 11 |
| Python | 36 | 13 |
| Go | 32 | 9 |
| Dart | 27 | 11 |
| PHP | 38 | 12 |
Floors are deliberately low: Node 18, Python 3.9, Go 1.21, PHP 8.1. An SDK that forces a runtime upgrade is not a convenience.
Start
npm install @goalapi/sdk
Get a key at goal-api.com/signup — free tier, no card.
Authorization: Bearer <API_KEY> is the only accepted form, and the base URL is
https://api.goal-api.com/v1.
One rule that applies to the browser and Flutter builds especially: a key shipped inside a client binary is a public key. Proxy through your own backend for anything user-facing.


