API Reference

The WC26 Threads API.

Base URL

All endpoints are served over HTTPS under the /api path:

https://wc26.adelash.dev/api

A simple health check lives at the API root — it returns service status and the running build version.

GET /api/
→ 200 { "status": "ok", "version": "1.0.0" }

Authentication

The API uses stateless JWT authentication. There are no sessions on the server — every authenticated request carries its own token.

The flow is three steps:

  • Register or log in — both return a signed JWT.
  • Store the token client-side (the Android app uses encrypted DataStore).
  • Send it on protected requests via the Authorization header.
# 1. Register — returns a token
POST /api/auth/register
# 2. Use the token on protected routes
Authorization: Bearer <your-jwt>
Tokens expire after 24 hours. When a token expires, the client logs in again to obtain a fresh one. There is no refresh-token flow in V1 — a deliberate scope decision.

Many read endpoints support optional authentication: they work without a token, but return richer data when one is supplied. For example, the posts feed includes a likedByCurrentUser flag on each post only when the request is authenticated.

Pagination

The API uses two pagination strategies, each suited to its data.

Offset pagination — for bounded datasets like matches and comments. Pass limit and offset query parameters. Responses include a total count.

GET /api/matches?limit=20&offset=40

Cursor pagination — for unbounded, infinite-scroll feeds like posts and likes. The response carries a nextCursor string; pass it back as the cursor parameter to fetch the next page. When nextCursor is null, there are no more results.

GET /api/posts?limit=20
→ { "items": [...], "nextCursor": "MjAyNi0wNS0xMHwxNQ==" }
GET /api/posts?limit=20&cursor=MjAyNi0wNS0xMHwxNQ==
Cursors are opaque, base64-encoded strings. Clients should treat them as tokens to pass back verbatim — not parse or construct them.

Errors

Errors return the appropriate HTTP status code with a JSON body containing an error field describing the problem.

→ 400 { "error": "Content must be 500 characters or less" }

Common status codes across the API:

200 OK 201 Created 204 No Content 400 Bad Request 401 Unauthorized 403 Forbidden 404 Not Found 409 Conflict 500 Server Error

Matches

All 104 World Cup matches, seeded into the database. Read-only for regular users; scores and status are updated by admins.

GET /matches public

List matches, paginated. Filterable by status and stage.

Query limit, offset, status (scheduled / live / finished), stage
Returns Offset page of matches with total count
GET /matches/{id} public

Fetch a single match by its ID.

Status 200 found 404 no such match

Auth

Registration, login, and the current-user lookup. Register and login return a JWT valid for 24 hours.

POST /auth/register public

Create an account. Returns a JWT and the new user.

Body email, username, password, displayName
Rules Username: 3–20 chars, starts with a letter, lowercase letters / digits / underscore. Password: min 8 chars.
Status 201 created 400 validation failed 409 email / username taken
POST /api/auth/register
{
  "email": "fan@example.com",
  "username": "supporter",
  "password": "a-strong-password",
  "displayName": "A Fan"
}
POST /auth/login public

Exchange email and password for a JWT.

Body email, password
Status 200 token issued 401 invalid credentials
Note A wrong password and an unknown email both return the same generic 401 — no account enumeration.
GET /auth/me auth required

Return the full profile of the authenticated user, including email and role.

Users

Public user profiles. Email and role are never exposed here — only through /auth/me for the user themselves.

GET /users/{id} public

Public profile: id, username, display name, avatar, join date.

GET /users/{id}/posts optional auth

A user's posts, newest first, cursor-paginated. With auth, each post carries likedByCurrentUser.

Query limit, cursor
GET /users/{id}/likes optional auth

Posts a user has liked, cursor-paginated. The likedByCurrentUser flag reflects the viewer's likes, not the profile owner's.

Posts

Posts are takes attached to a match. Each post belongs to one match and one author, and carries denormalized like and comment counts.

GET /posts optional auth

Global feed of all posts, newest first, cursor-paginated.

Query limit, cursor
GET /matches/{id}/posts optional auth

Posts for one match — the match thread. Cursor-paginated, newest first.

GET /posts/{id} optional auth

A single post with its author and counts.

POST /matches/{id}/posts auth required

Create a post on a match.

Body content — 1 to 500 characters
Status 201 created 400 empty / too long 404 no such match
DELETE /posts/{id} auth required

Delete a post. Only the author may delete their own post.

Status 204 deleted 403 not the author 404 no such post

Likes

Liking is idempotent — liking an already-liked post is a no-op, not an error. The post's likeCount is kept accurate with atomic counter updates.

POST /posts/{id}/like auth required

Like a post. Idempotent — returns 204 whether or not the post was already liked.

Status 204 liked 404 no such post
DELETE /posts/{id}/like auth required

Remove a like. Idempotent — returns 204 even if the post was not liked.

GET /posts/{id}/likes public

List the users who liked a post, cursor-paginated.

Comments

Comments are replies on a post, newest first. Posts carry a denormalized commentCount.

GET /posts/{id}/comments public

List comments on a post, newest first, offset-paginated.

Query limit, offset
POST /posts/{id}/comments auth required

Add a comment to a post.

Body content — 1 to 300 characters
Status 201 created 400 empty / too long 404 no such post
DELETE /comments/{id} auth required

Delete a comment. Only the comment's author may delete it.

Status 204 deleted 403 not the author 404 no such comment

Admin

Admin-only endpoints. These require a JWT whose role claim is admin — enforced by a dedicated authentication provider, not an inline check.

PATCH /admin/matches/{id} admin only

Update a match's score and / or status. Partial update — send only the fields to change.

Body homeScore, awayScore, status — all optional
Rules Scores must be provided together and be non-negative. At least one field is required.
Status 200 updated 400 invalid / empty 403 not an admin 404 no such match
Open source. The full implementation — every endpoint above, the schema migrations, the deployment setup — is on GitHub.