Skip to content

API reference

Everything is JSON. IDs are generated by the server, so never send an id.

Base URLs

Where Base URL
dotnet watch http://localhost:5008
Vite dev server http://localhost:5173 (proxies /api to :5008)
Full Docker, locally http://localhost:8090
Production, over the SSH tunnel http://localhost:8090
Production, public https://51.79.242.169.nip.io — /Health only

Only /Health is publicly reachable

Caddy is default-deny. Every other path on the public domain returns 404 with an empty body, including the admin API. Use the SSH tunnel.

Authentication

Every endpoint except /Health and POST /api/admin/login needs a logged-in admin session cookie. /api/admin/admins needs the owner specifically.

The cookie (hakutaku_admin_session) is HttpOnly, so a browser handles it automatically and JavaScript cannot read it. With curl you need a cookie jar: -c to save, -b to send.

See Auth and sessions for the mechanics.

Every endpoint

Method Path Guard Purpose
GET /Health — Liveness check
POST /api/admin/login Rate-limited Sign in, sets the session cookie
POST /api/admin/logout Session Revoke the session, clear the cookie
GET /api/admin/me Session Who the current cookie belongs to
GET /api/admin/admins Owner List all admins, including deactivated
POST /api/admin/admins Owner Create an admin
DELETE /api/admin/admins/{id} Owner Deactivate an admin
GET /api/players Admin List all players
POST /api/players Admin Create a player
GET /api/characters Admin List all characters
POST /api/characters Admin Create a character
GET /api/events Admin List all telemetry events
POST /api/events Admin Record a telemetry event

There is no PUT and no DELETE on game data, and list endpoints return the entire table — no paging, no filtering.

Planned changes

The game-data paths are intended to become singular (/api/players → /api/player), and to become publicly reachable with their own auth rather than admin-gated. Neither has happened yet.


Health

curl http://localhost:5008/Health
# {"health":"ok"}

Public through Caddy, because the Jenkins smoke test uses it.

The capital H matters

The route is /Health, and Caddy's handle /Health matcher is case-sensitive. /health is not published.


Admin: sign in

curl -c cookies.txt -X POST http://localhost:5008/api/admin/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"<password>"}'
# 204, Set-Cookie: hakutaku_admin_session=...

Body: username (string), password (string).

Login is by username, not email. Matching is case-insensitive and the username is trimmed.

Status When Body
204 Success Empty — the cookie is in the header
400 Missing field, or username > 254 / password > 256 chars { "error": "username and password are required" }
401 Wrong password, unknown username, or disabled account { "error": "invalid credentials" }
429 More than 5 attempts per minute from one IP Empty body

Two client traps here

204 means res.json() throws — there is no body to parse. And 429 is the only error status without an { error } body, because it comes from the framework's rate limiter rather than from handler code.

All three failure cases return the same 401 on purpose, so the API does not reveal which usernames exist.

Admin: who am I

curl -b cookies.txt http://localhost:5008/api/admin/me
# {"username":"admin","email":null,"role":"owner"}

role is "owner" or "admin". Nothing else is possible.

This is how a front end discovers whether its cookie is still good without asking for a password again. 401 if there is no cookie, or the session has expired or been revoked.

Never call this on a timer

It is an authenticated request, so it pushes the 30-minute idle expiry forward. Polling it means the idle timeout can never fire. Call it on app load and on route changes.

Admin: sign out

curl -b cookies.txt -X POST http://localhost:5008/api/admin/logout
# 204, clears the cookie

Returns 401 if you were not logged in. Treat that as "already logged out" rather than an error.

Admin: list admins

curl -b cookies.txt http://localhost:5008/api/admin/admins
[
  { "id": 1, "username": "admin", "role": "owner",
    "createdAt": "2026-09-25T09:52:16Z", "disabledAt": null },
  { "id": 5, "username": "bob", "role": "admin",
    "createdAt": "2026-10-01T04:10:00Z", "disabledAt": "2026-10-01T05:00:00Z" }
]

Owner only. Includes deactivated admins — disabledAt is non-null for those — so the UI can show state and target a deactivation.

Ordered by id.

Admin: create an admin

curl -b cookies.txt -X POST http://localhost:5008/api/admin/admins \
  -H "Content-Type: application/json" \
  -d '{"username":"bob","password":"a-real-password"}'
# 200 {"id":5,"username":"bob","role":"admin"}

Body: username (string, ≤ 254), password (string, 8–256).

Always creates a regular admin. There is exactly one owner — seeded at first start — and this endpoint cannot create another.

Status When
200 Created
400 Missing username, or password outside 8–256 characters
401 Not logged in
403 Logged in as a non-owner admin — { "error": "owner only" }
409 Username already taken (case-insensitively)

Admin: deactivate an admin

curl -b cookies.txt -X DELETE http://localhost:5008/api/admin/admins/5
# 204

Sets disabled_at and immediately revokes that admin's active sessions. Does not hard-delete the row, and there is no restore endpoint.

Status When
204 Deactivated
401 Not logged in
403 Not the owner, or the target is the owner
404 No such admin

One-way, and it burns the username

The unique index on lower(username) covers disabled rows, so that username can never be reused.

The owner account can never be deactivated, whoever asks. The seeder only runs when there are zero admins, so disabling the sole owner would lock everyone out with no HTTP way back in.


Game data

All three need an admin session today. IDs are GUIDs — treat them as opaque strings; do not parse or format them.

Request bodies

Endpoint Fields
POST /api/players deviceId (string), xp (integer)
POST /api/characters playerId (GUID of an existing player), name (string)
POST /api/events playerId (GUID of an existing player), eventType (string), data (string holding JSON text)

timestamp on an event is always set by the server in UTC. Anything you send for it is overwritten.

Responses echo the created object. Character and event responses also include "player": null, which you can ignore — it is an unpopulated navigation property.

Worked example

curl -b cookies.txt -X POST http://localhost:5008/api/players \
  -H "Content-Type: application/json" \
  -d '{"deviceId":"device-1","xp":0}'
# {"id":"<player-id>","deviceId":"device-1","xp":0}

curl -b cookies.txt -X POST http://localhost:5008/api/characters \
  -H "Content-Type: application/json" \
  -d '{"playerId":"<player-id>","name":"Hero"}'

curl -b cookies.txt -X POST http://localhost:5008/api/events \
  -H "Content-Type: application/json" \
  -d '{"playerId":"<player-id>","eventType":"level_up","data":"{\"level\":2}"}'

Error shapes

Four different shapes, and a client has to handle all of them:

Shape Which responses
{ "error": "message" } 400, 401, 403, 404, 409
Empty body 204 (success), and 429
Bare 500 A playerId that does not exist — an uncaught foreign-key violation
404 with empty body, Server: Caddy Caddy's default-deny, not the app at all

web/src/api.ts is a working reference for handling these in one place.

Malformed JSON returns 400

From the framework's model binding, before any handler runs.

What does not exist

Asked for often enough to be worth listing:

Wanted Status
GET /api/players/{id} No — list endpoints only
Filtering a player's characters or events No
Paging No — every list returns the whole table
PUT / PATCH / DELETE on game data No
Player login No — no player-facing auth at all
Server keys for game servers No — blocks the simulator
Game-server endpoints No entity exists
An audit log No table exists
Password change / reset No
Restoring a deactivated admin No, by design