goal-api
Python SDK for the GOAL API: football fixtures, live scores, standings, player stats, odds and live WebSocket updates.
Sync and async clients from the same surface. Python 3.9+.
pip install goal-api # REST
pip install "goal-api[live]" # + WebSocket live updates
Quick start
import os
from goal_api import GoalAPI
goal = GoalAPI(os.environ["GOAL_API_KEY"])
for match in goal.fixtures.live()["data"]:
print(match["homeTeam"]["name"], match["homeScore"], "-", match["awayScore"], match["awayTeam"]["name"])
Get a key at goal-api.com/signup.
Use it as a context manager so the connection pool closes cleanly:
with GoalAPI(api_key) as goal:
table = goal.leagues.standings(league_id)["data"]
Async
Same method names, awaited:
import asyncio
from goal_api import AsyncGoalAPI
async def main():
async with AsyncGoalAPI(api_key) as goal:
live, results = await asyncio.gather(
goal.fixtures.live(),
goal.results.today(),
)
print(len(live["data"]), "in play,", len(results["data"]), "finished today")
asyncio.run(main())
Client options
goal = GoalAPI(
api_key,
base_url="https://api.goal-api.com/v1", # default
timeout=30.0, # per attempt, seconds
max_retries=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.
Endpoints
Grouped by resource. Query params are keyword arguments, passed through with the API's own
camelCase names. Full reference in ENDPOINTS.md.
goal.status.get() # no API key needed
goal.countries.list(search="spa")
goal.leagues.list(isActive=True, limit=100)
goal.leagues.standings(league_id)
goal.leagues.top_scorers(league_id, limit=10)
goal.teams.get(team_id, includePlayers=True)
goal.teams.statistics(team_id, season="2025-2026")
goal.fixtures.list(**{"from": "2026-08-01", "to": "2026-08-07", "status": "SCHEDULED"})
goal.fixtures.by_date("2026-08-15", leagueId=league_id)
goal.fixtures.lineups(fixture_id)
goal.fixtures.statistics(fixture_id, half="1half")
goal.standings.form(league_id)
goal.players.search("haaland", limit=5)
goal.players.compare([player_a, player_b])
goal.players.top("goals", limit=20)
goal.coaches.by_team(team_id)
goal.h2h.stats(team_a, team_b)
goal.results.today()
goal.videos.recent(leagueId=league_id, limit=10)
goal.odds.list(bookmaker="bet365")
goal.predictions.list(matchId=match_id)
from is a Python keyword, so date ranges need **{...} or a prepared dict:
window = {"from": "2026-08-01", "to": "2026-08-31"}
goal.leagues.fixtures(league_id, **window)
Every method returns the raw envelope, so pagination and source stay reachable:
page = goal.teams.list(leagueId=league_id, limit=50)
page["data"] # list of teams
page["pagination"]["hasMore"] # bool
page["source"] # "cache" | "database"
One exception: goal.status.*
The five /public/* endpoints don't use the {"success", "data"} envelope. They return
bare objects, so read them directly with no ["data"]:
status = 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 coverage_leagues.
Pagination
paginate walks pages and yields items:
for team in goal.paginate(lambda **p: goal.leagues.teams(league_id, **p)):
print(team["name"])
# Or collect, with a cap. /results accepts limit up to 500:
recent = goal.collect(
lambda **p: goal.results.list(leagueId=league_id, **p),
page_size=500,
max_items=500,
)
Async is the same call, iterated with async for:
async for team in goal.paginate(lambda **p: goal.leagues.teams(league_id, **p)):
...
Default page_size is 100, the limit ceiling on most endpoints. /results and
/countries take 500.
Errors
Everything raised is a GoalAPIError. Branch only where you'd actually behave
differently:
from goal_api import GoalAPIError, NotFoundError, RateLimitError, ValidationError
try:
fixture = goal.fixtures.get(fixture_id)
except NotFoundError:
fixture = None
except RateLimitError as error:
print(f"Quota exhausted ({error.rate_limit_type}), retry in {error.retry_after}s")
raise
except ValidationError as error:
print(error.details) # which field the server rejected
raise
except GoalAPIError as error:
print(error.code, error.correlation_id)
raise
Two error shapes
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.correlation_id is
only set on gateway errors. Quote it in a support ticket when you have it.
Rate limits
goal.fixtures.live()
goal.rate_limit.remaining # int | None
goal.rate_limit.reset # unix seconds
goal.rate_limit.type # "DAILY" | "MONTHLY"
Live WebSocket updates
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.
Needs the live extra. WebSockets are async, so this is async even on the sync client.
import asyncio
from goal_api import GoalAPI
async def watch(fixture_id):
goal = GoalAPI(api_key)
live = goal.live()
async with live:
live.subscribe(fixture_id)
async for message in live:
if message["type"] == "match_update":
print(message["data"])
asyncio.run(watch("fixture-id"))
Or register handlers and run in the background:
live.on("match_update", lambda m: print(m["data"]))
live.on("error", lambda m: print("error:", m))
await live.connect()
live.subscribe(fixture_id)
await live.run_forever()
- Python clients authenticate the handshake with the
Authorizationheader, so no token round trip. - Reconnects with backoff and replays your subscriptions.
await live.close()opts out. subscribe()beforeconnect()is fine; it's replayed on open.- Server caps client messages at 60/minute and concurrent subscriptions by plan.
- A slow consumer drops the oldest queued message rather than stalling the socket.
Message types: match_update, auth_success, status, pong, server_shutdown,
error. Use on("*", ...) for everything.
Handing a token to a browser
Your frontend must never see your API key. Mint a single-use token server-side instead:
from goal_api.live import mint_connect_token
token = mint_connect_token(goal)["data"]["token"]
# browser: new WebSocket(`wss://api.goal-api.com/v1/ws?wsToken=${token}`)
Webhooks
Verify against the raw body. A parsed-and-reserialized dict has different bytes and will never match.
from fastapi import FastAPI, Request, Response
from goal_api import verify_webhook, WebhookSignatureError
app = FastAPI()
@app.post("/goal-webhooks")
async def hook(request: Request):
try:
event = verify_webhook(
await request.body(),
request.headers.get("x-goal-signature"),
os.environ["GOAL_WEBHOOK_SECRET"],
)
except WebhookSignatureError:
return Response(status_code=400)
if request.headers.get("x-goal-event") == "goal.scored":
...
return Response(status_code=200) # ack fast; retries are ~1m, 5m, 25m, 2h, 10h
Timestamps outside 300s are rejected as replays. Override with tolerance=.
Escape hatch
For an endpoint this SDK doesn't wrap yet:
data = goal.request("/some/new/endpoint", {"limit": 10})
Constants
from goal_api import MATCH_STATUSES, PLAYER_TYPES, PLAYER_STATS, HALVES
Examples
| File | Shows |
|---|---|
examples/basic.py |
Status, live fixtures, standings, pagination |
examples/live_scores.py |
The live socket: connect, subscribe, print every frame |
examples/webhook_server.py |
Verifying a webhook against the raw request bytes |
examples/bulk_export.py |
Walking every page of a collection to CSV |
GOAL_API_KEY=... python examples/live_scores.py
GOAL_WEBHOOK_SECRET=... python examples/webhook_server.py
GOAL_API_KEY=... python examples/bulk_export.py > countries.csv
Testing
pip install -e ".[dev]"
pytest -q # unit tests, no network
GOAL_API_KEY=... pytest -q # also runs the live tests against the real API
The live tests skip themselves without a key. Endpoint-by-endpoint coverage of the API
lives in tools/sweep.py in the SDK workspace.
Licence
MIT. See LICENSE.
Runtime dependencies and their licences are in
THIRD_PARTY_NOTICES.md. The short version: httpx (BSD-3-Clause),
plus websockets (BSD-3-Clause) only if you install the live extra. One transitive
dependency, certifi, is MPL-2.0 rather than permissive, which is called out there in case
your licence policy cares.
Security issues: SECURITY.md.
Release files for goal-api 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| goal_api-1.0.0.tar.gz | 35.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| goal_api-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:57.5 kB
Release files / goal_api-1.0.0.tar.gz
| Download URL | goal_api-1.0.0.tar.gz |
|---|---|
| Size | 35.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2a4a29cd8e4de59dec4cc26a029426a2a1fb878accb056893f5c7475e80a90da
|
|
BLAKE2b-256 checksum How to use checksums |
9dbb5901267ff3e53e6fef0eaabd26f0f9306ec742ee2e1fbde171fe86fb0155
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logRelease files / goal_api-1.0.0-py3-none-any.whl
| Download URL | goal_api-1.0.0-py3-none-any.whl |
|---|---|
| Size | 21.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d8a2f0f1e61c330a0f4a3ba41d319da3afdeb7260aee081c4f632eaffc5f5230
|
|
BLAKE2b-256 checksum How to use checksums |
2edd71029aa2f1ca87e6b6d1911fe05c2afc8ccd9e95c6256acb3484213f171a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency log