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 are provider-shaped 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.