Every league's table, fixtures, season statistics, box scores, bracket and awards, as JSON. All of it is public already — these are the same numbers the pages show anyone who visits. The key is not a gate; it is an identity, so a league can see who is pulling what and stop one runaway script without taking the API down for everybody else.
A league administrator issues keys from the admin console. A key belongs to one league and reads only that league. It is shown once, at the moment it is created — only a hash is stored, so it cannot be looked up afterwards by anyone, including us. Lost keys are revoked and replaced, not recovered.
Send it as a header, which is the form that will not end up in a server log or a referrer:
curl -H "X-API-Key: epk_your_key_here" \ https://hhvofgqqadtyvcjudhjx.supabase.co/functions/v1/api/v1/leagues
?key= works too, for a browser address bar or a spreadsheet that cannot set headers.
| Path | What it returns |
|---|---|
| GET /v1/leagues | The leagues your key can read. |
| GET /v1/leagues/{slug} | The current season and its competitions, with the ids you pass to ?competition=. |
| GET /v1/leagues/{slug}/standings | The table. Grouped competitions carry a group on every row. |
| GET /v1/leagues/{slug}/games | Fixtures and results, newest first. ?status=final|live|scheduled. |
| GET /v1/leagues/{slug}/players | Season statistics, totals and per game, ordered by points. |
| GET /v1/leagues/{slug}/awards | Season awards, each with the basis it was decided on. |
| GET /v1/leagues/{slug}/bracket | Knockout ties, seeds, aggregates and winners. |
| GET /v1/games/{id} | A full box score, both sides, once the game is final. |
Every collection takes ?limit= (up to 200, 50 by default) and
?offset=, and echoes both back beside the data so a short page
can be told from the end of a list. Anything scoped to a competition takes
?competition=; without one you get the current season's first.
Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset
(a Unix timestamp). Over the limit you get 429 with a
Retry-After. The window is one hour and it is counted per key.
{
"competition": { "id": "…", "name": "Division One" },
"standings": [
{
"rank": 1,
"group": null,
"team": { "name": "East Dock", "short_name": "ED",
"slug": "east-dock", "colour": "#ffd166" },
"played": 6, "won": 4, "lost": 2,
"points_for": 506, "points_against": 527,
"difference": -21, "league_points": 10, "streak": "W2"
}
]
}
A team is an object with a name and a slug rather than a bare id you would
have to resolve with a second request, and internal identifiers stay
internal. Slugs are what the public pages use, so
/epinoia/t/?t=east-dock is the human page for that row.
Everything above is a pull. If you republish results — RealGM, Eurobasket, a club site — you probably do not want to poll a fixture list at 2am on the off-chance. Ask the league for a scraper key and we post each game to you the moment it is finalised, in the shape your importer already reads.
A league administrator sets this up per partner and chooses:
format (JSON, CSV or XML), which sections
to include (the fixture, team totals, the box score, the table, and
optionally full play-by-play), how names are written
(Ada Shaw, Shaw, Ada, Shaw Ada,
Ada SHAW), how dates are written (ISO,
dd/mm/yyyy, mm/dd/yyyy or Unix seconds), and a field map
that renames our keys to yours — {"reb":"TRB","fg3a":"3PA"}
applies to JSON keys, CSV headers and XML element names alike.
Our own spelling is stable and deliberately conventional:
pts fgm fga fg2m fg2a fg3m fg3a ftm fta oreb dreb reb ast stl blk
tov pf plus_minus minutes. It does not follow our engine's
internals, so it will not change when they do.
| Header | What it is |
|---|---|
X-Epinoia-Event | game.final. |
X-Epinoia-Delivery | A stable id for this delivery. A re-finalised game is resent with a new body under the same id — treat it as an update, not a duplicate. |
X-Epinoia-Timestamp | Unix seconds at send. |
X-Epinoia-Signature | sha256=… — HMAC-SHA256 of the exact body bytes, keyed on the shared secret. |
Verify the signature before you trust a delivery. These numbers get republished as fact, and anyone who learns your endpoint could otherwise post to it:
import hmac, hashlib
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Epinoia-Signature"]):
return 401
Answer 2xx and we mark it delivered. Anything else is
recorded as failed and can be resent from the league's console without
replaying the game, so a night when your endpoint was down is a button
rather than a support thread. We follow no redirects and we give up after
ten seconds.
notice
saying so rather than leaving you to find the discrepancy.
| Status | Meaning |
|---|---|
| 401 | No key, an unknown key, or a revoked one. The message does not distinguish an unknown key from a revoked one on purpose. |
| 403 | A valid key, for a different league. |
| 404 | No such league, game or collection. The body names what does exist. |
| 405 | The API is read-only. |
| 429 | Over the hourly limit. Retry-After says how long. |
Errors are always JSON, with an error and usually a hint.