StatsHub Docs

api

The Go HTTP API — a route-for-route port of legacy's pages/api, on port 8080, with a described /api/v2 surface beside it.

apps/statshub-api serves the StatsHub HTTP API in Go. It is a port of the 218 Next.js route files in apps/legacy/src/pages/api, reading the same Postgres through the same schema and serving the same paths, query parameters and JSON shapes.

bun run dev:api             # http://localhost:8080
cd apps/statshub-api && make dev     # equivalent, from the app
Go 1.25chiroutingBunthe query builderpgx / PostgreSQLSupabasesession resolutionOpenAPI 3.1generated from the handlershumathe v2 runtimeDocker

What it does

Fixtures, teams, players, referees

The entity reads every client starts from — one match, one squad, one player's log, one official's record.

The models

valuebets, shotmodel and odds are packages, not endpoints: the value board, the shot model and the odds comparison that the boards on web render.

Stats and screeners

stats and bookmakers serve the hit rates, trends and per-book prices the prop screener filters on.

Bots and gateways

telegram posts the alerts, aigateway fronts the model calls, stripe handles subscriptions, websearch and notion fetch what the rest needs.

Sitemaps

sitemap enumerates the entity URLs worth crawling — filtered to visible tournaments, and to finished fixtures from the last 60 days. It is also what web prerenders from.

A described surface

/api/v2 is the same 298 operations behind typed structs, one error model, and an OpenAPI document you can generate a client from. The reference is that document, rendered.

How it is built

The OpenAPI document is read out of the code

cmd/openapigen walks the router and handlers with go/types and writes internal/api/v2_gen.go. The description is recovered from the implementation rather than maintained beside it, so the two cannot drift — and openapi:check fails a build where someone added a route and did not regenerate.

Writes to production are blocked in-process

internal/database/readonly.go classifies every statement on its way out and refuses anything that is not a read — at the driver, because a Bun BeforeQuery hook returns a context and not an error. It fails closed: a statement it cannot classify counts as a write.

Two surfaces, one implementation

/api is byte-compatible with the Next.js routes it was ported from — untyped, with four different error envelopes. /api/v2 is the same handlers behind typed responses. Neither is a reimplementation of the other.

Faithfulness is tested, not asserted

Golden responses recorded from legacy and replayed against Go, and a conformance test that checks the declared v2 types against those same recordings — so the document cannot claim a field the handler stopped writing.

82 tables, twice

internal/schema is the Drizzle schema legacy uses, restated as Bun models. It is a second copy of one definition and stays that way until legacy is gone, which is the cost of running both against one database.

The cache is bounded on purpose

internal/cache is an LRU with a per-entry TTL, ported from bounded-cache.ts. The Node original replaced module-level new Map() caches that grew until the heap cap was hit; the entry cap is what stops that happening again.

GET /api/health answers as soon as the process is up; everything else needs DATABASE_URL. scripts/dev.sh loads it from the root .env, then apps/statshub-api/.env, then .env.local, because Go has no equivalent of the automatic .env loading the Next.js apps get.

Which database you are attached to

Three databases can back the API, and the third one is production. Which one you get is decided by the command you run, not by whichever file last set DATABASE_URL.

CommandDatabaseWrites
make devLocal Postgres, from .env / .env.localAllowed
bun run parity:upThe seeded fixture containerAllowed
make dev-remoteThe real one online, from .env.remoteBlocked
cp apps/statshub-api/.env.remote.example apps/statshub-api/.env.remote   # paste the URI, then
cd apps/statshub-api && make dev-remote

Supabase hands you the string under Project Settings → Database → Connection string → URI. Take the transaction pooler one on port 6543: the pool limits and the simple query protocol in internal/database are built for it.

.env.remote is read by nothing else and is gitignored. That separation is the point — make dev cannot pick up production because a file was edited weeks ago and forgotten.

Writes are blocked, and that is enforced in the process

Attaching to production opens the connection read-only. Every statement passes internal/database/readonly.go on its way out, and anything that is not a read is refused before it reaches Postgres:

{"level":"WARN","msg":"attached to a REMOTE database","host":"…pooler.supabase.com","writes":"blocked (read-only)"}

The check sits at the driver rather than in a bun query hook because a hook cannot fail a query — BeforeQuery returns a context, not an error. It sits in the application rather than relying on SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY because a transaction pooler does not carry session state across transactions, so the server-side setting is not dependable through Supabase's pooler.

The classifier fails closed. A statement it cannot classify counts as a write, WITH … AS (DELETE … RETURNING) SELECT is caught rather than passing as a SELECT, and comments and string literals are stripped first so a row whose text happens to say delete does not block a read.

To write — a migration, or reproducing a write path — say so on the command line. There is no config file for it, so it cannot be left on by accident:

STATSHUB_DB_WRITES=allow make dev-remote

STATSHUB_DB_MODE=local|remote forces the mode when a tunnel makes production look like localhost. Anything the process cannot prove is local is treated as remote, which is the safe direction: the cost is a rejected write rather than a row in production.

Two surfaces, one implementation

PrefixContract
/apiByte-compatible with the Next.js routes it was ported from. Unchanged, untyped, four different error envelopes.
/api/v2The same 298 operations through typed Go structs, one application/problem+json error model, and an OpenAPI document generated from those structs.

The endpoint reference is generated from that document, and OpenAPI and /api/v2 explains how the types are recovered and what the generator deliberately leaves open.

cd apps/statshub-api
bun run openapi        # regenerate internal/api/v2_gen.go and docs/openapi.json
bun run openapi:check  # the same, then fail if either changed

openapi:check is what belongs in CI: a route added without regenerating shows up as a diff.

The port is in progress

apps/statshub-api/docs/go-port.md lists every legacy route and whether it has moved across. It is generated — edit apps/statshub-api/docs/.ported and run:

cd apps/statshub-api && make porting-status

That ledger is a data file, not a doc

packages/statshub-api-contract parses its ported-routes table to learn which routes claim to be ported, and update_manifest.py rewrites the whole file. Do not hand-edit the tables, and do not move the file without updating packages/statshub-api-contract/src/cli.ts, which pins the path.

Layout

PackageWhat it owns
cmd/apiBootstrap and graceful shutdown
cmd/openapigenReads the router and handlers with go/types, writes internal/api/v2_gen.go
cmd/openapispecSerialises the document to docs/openapi.json
internal/configEnvironment parsing; PORT defaults to 8080
internal/databasepgx pool + Bun, pooled to match legacy
internal/schemaThe 82 Drizzle tables as Bun models
internal/httpxRequest parsing and the legacy response conventions
internal/authSupabase session resolution
internal/routerchi routing
internal/apiThe generated v2 surface and its conformance test

Domain packages sit alongside those — valuebets, odds, shotmodel, stats, bookmakers, telegram, stripe, sitemap, aigateway and the rest.

Faithfulness is tested, not assumed

Two gates, and they check different things:

  • statshub-api-contract records golden responses from legacy and replays them against Go. This is what proves the bytes match.
  • internal/api/v2_conformance_test.go checks every declared v2 response type against those same recordings, so the OpenAPI document cannot claim a field the handler stopped writing.

Read both before changing a serialisation. A shape change that passes Go's own tests can still break every client.

On this page