JavaScript and TypeScript SDK for the GOAL API: football fixtures, live scores, standings, player stats, odds and live WebSocket updates.
Zero runtime dependencies. Works in Node 18+, Deno, Bun, Cloudflare Workers and browsers.
npm install @goalapi/sdkimport { GoalApi } from '@goalapi/sdk';
const goal = new GoalApi({ apiKey: process.env.GOAL_API_KEY });
const { data: live } = await goal.fixtures.live();
for (const match of live) {
console.log(`${match.homeTeam?.name} ${match.homeScore}-${match.awayScore} ${match.awayTeam?.name}`);
}Get a key at goal-api.com/signup. Don't ship it in client-side code; proxy through your own backend.
const goal = new GoalApi({
apiKey: process.env.GOAL_API_KEY,
baseUrl: 'https://api.goal-api.com/v1', // default
timeout: 30_000, // per attempt, ms
maxRetries: 2, // 429 + 5xx + network errors
headers: { 'X-My-App': 'scoreboard' },
});Retries use exponential backoff with full jitter, and always honour a server-sent
Retry-After. Aborted requests are never retried.
Grouped by resource. Full parameter reference in ENDPOINTS.md.
await goal.status.get(); // no API key needed
await goal.countries.list({ search: 'spa' });
await goal.leagues.list({ isActive: true, limit: 100 });
await goal.leagues.standings(leagueId);
await goal.leagues.topScorers(leagueId, { limit: 10 });
await goal.teams.get(teamId, { includePlayers: true });
await goal.teams.statistics(teamId, { season: '2025-2026' });
await goal.fixtures.list({ from: '2026-08-01', to: '2026-08-07', status: 'SCHEDULED' });
await goal.fixtures.byDate('2026-08-15', { leagueId });
await goal.fixtures.lineups(fixtureId);
await goal.fixtures.statistics(fixtureId, { half: '1half' });
await goal.standings.form(leagueId);
await goal.players.search('haaland', { limit: 5 });
await goal.players.compare([playerA, playerB]);
await goal.players.top('goals', { limit: 20 });
await goal.coaches.byTeam(teamId);
await goal.h2h.stats(teamA, teamB);
await goal.results.today();
await goal.videos.recent({ leagueId, limit: 10 });
await goal.odds.list({ bookmaker: 'bet365' });
await goal.predictions.list({ matchId });Every method returns the raw envelope, so pagination and source stay reachable:
const page = await goal.teams.list({ leagueId, limit: 50 });
page.data; // Team[]
page.pagination.hasMore; // boolean
page.source; // 'cache' | 'database'The five /public/* endpoints don't use the { success, data } envelope. They return
bare objects, so read them directly with no .data:
const status = await goal.status.get();
status.status; // 'operational'
status.components; // [{ name, status, uptime }]They also paginate with page/limit instead of limit/offset, so paginate() does
not apply to coverageLeagues.
paginate walks pages for you and yields items:
for await (const team of goal.paginate((p) => goal.teams.list({ leagueId, ...p }))) {
console.log(team.name);
}
// Or collect, with a cap:
const first500 = await goal.collect(
(p) => goal.results.list({ leagueId, ...p }),
{ pageSize: 500, maxItems: 500 }, // /results accepts limit up to 500
);Default pageSize is 100, the limit ceiling on most endpoints. /results and
/countries take 500.
Everything thrown is a GoalApiError. Branch only where you'd actually behave
differently:
import { RateLimitError, ValidationError, NotFoundError, PlanUpgradeRequiredError } from '@goalapi/sdk';
try {
await goal.fixtures.get(id);
} catch (error) {
if (error instanceof NotFoundError) return null;
if (error instanceof RateLimitError) {
console.warn(`Quota exhausted (${error.rateLimitType}), retry in ${error.retryAfter}s`);
} else if (error instanceof ValidationError) {
console.error(error.details); // which field the server rejected
} else if (error instanceof PlanUpgradeRequiredError) {
console.error('This endpoint is not in your plan');
}
throw error;
}The API answers with one of two bodies, and the SDK normalises both:
| Gateway (auth, routing, rate limits) | Football service (most endpoints) | |
|---|---|---|
| text | message |
error |
code |
yes | yes |
category |
yes | no |
correlationId |
yes | no |
details |
object | array, on validation errors |
So error.message and error.code are always populated, and error.correlationId is
only set on gateway errors. Quote it in a support ticket when you have it.
The SDK records the headers from the last response:
await goal.fixtures.live();
goal.rateLimit; // { limit, remaining, reset, type: 'DAILY' | 'MONTHLY' }reset is a unix timestamp in seconds.
The socket is at
wss://api.goal-api.com/ws, not/v1/ws. Only nginx'slocation ^~ /wscarries theUpgradeheaders;/v1/wsis proxied as ordinary HTTP and answers 200 instead of upgrading. The SDK derives the right URL for you.Two services authenticate: the gateway authorises the upgrade from the header or
?wsToken=, then websocket-service needs an{"type": "auth", ...}frame as the very first message. The SDK sends it, and treatsauth_successas the point the connection is usable.
subscribeis capped per plan and the cap can be 0.auth_successreportsmaxSubscriptions; if it is 0 the socket works but nomatch_updatewill ever arrive. See the known server issue inENDPOINTS.md.
const live = goal.live();
live.on('match_update', ({ data }) => console.log(data));
live.on('error', (error) => console.error(error));
live.on('close', ({ code }) => console.log('closed', code));
await live.connect();
live.subscribe(fixtureId);- Node 22+: works as-is. On Node 18-21,
npm i wsand pass it:goal.live({ WebSocket: (await import('ws')).WebSocket }). - Browsers: the SDK mints a short-lived single-use token via
POST /ws/tokenautomatically. The key is still in the page, so put a proxy in front. - Reconnects automatically with backoff and replays your subscriptions.
close()opts out. subscribe()beforeconnect()is fine; it's queued and sent on open.- Server caps client messages at 60/minute and concurrent subscriptions by plan.
Message types you can listen for: match_update, auth_success, status, pong,
server_shutdown, error, plus the SDK's own open / close, and '*' for everything.
Verify against the raw body. A parsed-and-reserialized object has different bytes and will never match.
import express from 'express';
import { verifyWebhook, WebhookSignatureError } from '@goalapi/sdk';
const app = express();
app.post('/goal-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers['x-goal-signature'], process.env.GOAL_WEBHOOK_SECRET);
} catch (error) {
if (error instanceof WebhookSignatureError) return res.sendStatus(400);
throw error;
}
switch (req.headers['x-goal-event']) {
case 'goal.scored': /* ... */ break;
case 'match.finished': /* ... */ break;
}
res.sendStatus(200); // ack fast; the server retries at ~1m, 5m, 25m, 2h, 10h
});Signatures use the Stripe scheme: t=<unix>,v1=<hmac-sha256 of "timestamp.body">.
Timestamps outside 300s are rejected as replays. Override with { tolerance }.
For an endpoint this SDK doesn't wrap yet:
const data = await goal.request('/some/new/endpoint', { limit: 10 });Types ship with the package. Params, methods, enums and errors are fully typed.
Row shapes are not, since they follow the API's response shapes and change. Each endpoint method takes its row type as a type argument instead:
import { GoalApi, type MatchStatus } from '@goalapi/sdk';
interface Fixture { id: string; status: MatchStatus; homeScore: number | null }
const page = await goal.fixtures.live<Fixture>();
page.data[0].homeScore; // number | null
const raw = await goal.fixtures.live();
raw.data[0]['homeScore']; // Json, since no type argument was givenIt flows through pagination too, so there are no casts anywhere:
for await (const team of goal.paginate((p) => goal.teams.list<Team>(p))) {
team.name; // string
}| File | Shows |
|---|---|
examples/basic.js |
Status, live fixtures, standings, pagination |
examples/live-scores.js |
The live socket: connect, subscribe, print every frame |
examples/webhook-server.js |
Verifying a webhook against the raw request bytes |
examples/bulk-export.js |
Walking every page of a collection to CSV |
GOAL_API_KEY=... node examples/live-scores.js
GOAL_WEBHOOK_SECRET=... node examples/webhook-server.js
GOAL_API_KEY=... node examples/bulk-export.js > countries.csvnpm test # unit tests, no network
GOAL_API_KEY=... npm test # also runs the live tests against the real APIThe live tests skip themselves without a key, so the same command works either way.
Endpoint-by-endpoint coverage of the API lives in tools/sweep.py in the SDK workspace.
Four more first-party clients over the same API, with the same resource groups, the same retry and pagination behaviour and the same error types. All five release in lockstep, so a version number means the same surface everywhere.
| Language | Package | Install |
|---|---|---|
| Python | goal-api |
pip install goal-api |
| Go | goal-api-go |
go get github.com/goal-api/goal-api-go |
| Dart / Flutter | goal_api |
dart pub add goal_api |
| PHP | goal-api/sdk |
composer require goal-api/sdk |
Each one is its own repository and carries the same ENDPOINTS.md, the
API contract derived from the running service.
MIT. See LICENSE.
No runtime dependencies: this package uses the platform's own fetch, WebSocket and
node:crypto. The one optional peer dependency (ws, MIT, needed only on Node 18-21) and
the dev-only tooling are listed in THIRD_PARTY_NOTICES.md.
Security issues: SECURITY.md.