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

4 min read

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.

Keep reading

Official football API SDKs for JavaScript, Python, Go, Dart and PHP | GOAL API