Python SDK for the GOAL API: football fixtures, live scores, standings, stats and live WebSocket updates.
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file goal_api-1.0.0.tar.gz.
File metadata
- Download URL: goal_api-1.0.0.tar.gz
- Upload date:
- Size: 35.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a4a29cd8e4de59dec4cc26a029426a2a1fb878accb056893f5c7475e80a90da
|
|
| MD5 |
ac1702bc876a61a73c206938f3103ca2
|
|
| BLAKE2b-256 |
9dbb5901267ff3e53e6fef0eaabd26f0f9306ec742ee2e1fbde171fe86fb0155
|
Provenance
The following attestation bundles were made for goal_api-1.0.0.tar.gz:
Publisher:
publish.yml on goal-api/goal-api-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
goal_api-1.0.0.tar.gz -
Subject digest:
2a4a29cd8e4de59dec4cc26a029426a2a1fb878accb056893f5c7475e80a90da - Sigstore transparency entry: 2341326191
- Sigstore integration time:
-
Permalink:
goal-api/goal-api-python@c1e37abd7556d276c3166a4398756b9cd6512e6b -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/goal-api
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c1e37abd7556d276c3166a4398756b9cd6512e6b -
Trigger Event:
push
-
Statement type:
File details
Details for the file goal_api-1.0.0-py3-none-any.whl.
File metadata
- Download URL: goal_api-1.0.0-py3-none-any.whl
- Upload date:
- Size: 21.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d8a2f0f1e61c330a0f4a3ba41d319da3afdeb7260aee081c4f632eaffc5f5230
|
|
| MD5 |
3527e4e93d21ad817fcb2159c331c58a
|
|
| BLAKE2b-256 |
2edd71029aa2f1ca87e6b6d1911fe05c2afc8ccd9e95c6256acb3484213f171a
|
Provenance
The following attestation bundles were made for goal_api-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on goal-api/goal-api-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
goal_api-1.0.0-py3-none-any.whl -
Subject digest:
d8a2f0f1e61c330a0f4a3ba41d319da3afdeb7260aee081c4f632eaffc5f5230 - Sigstore transparency entry: 2341326204
- Sigstore integration time:
-
Permalink:
goal-api/goal-api-python@c1e37abd7556d276c3166a4398756b9cd6512e6b -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/goal-api
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c1e37abd7556d276c3166a4398756b9cd6512e6b -
Trigger Event:
push
-
Statement type: