EPINOIΛ JSON API · version 1

The API

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.

Getting a key

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.

Endpoints

PathWhat 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.

Rate limits

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.

Finished games do not change, so responses may be cached for a minute. If you are polling a live game, the public game page's websocket feed is the right tool — it is under a second behind the whistle, where polling this API is a minute behind at best.

What a response looks like

{
  "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.

Scraper keys — being sent games instead of asking for them

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.

HeaderWhat it is
X-Epinoia-Eventgame.final.
X-Epinoia-DeliveryA 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-TimestampUnix seconds at send.
X-Epinoia-Signaturesha256=… — 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.

Under-18 players are withheld from every feed, whatever is configured. Team totals still include their contribution, so the lines will not sum to the totals — the payload carries a notice saying so rather than leaving you to find the discrepancy.

Errors

StatusMeaning
401No key, an unknown key, or a revoked one. The message does not distinguish an unknown key from a revoked one on purpose.
403A valid key, for a different league.
404No such league, game or collection. The body names what does exist.
405The API is read-only.
429Over the hourly limit. Retry-After says how long.

Errors are always JSON, with an error and usually a hint.