API Conventions
Read this once; the per-domain pages assume it.
Base URL
| Environment | Base URL |
|---|---|
| Production | https://api.playpalz.gg/api/v1 |
| Local | http://localhost:4000/api/v1 |
Every documented path is relative to that base. GET /feed means GET https://api.playpalz.gg/api/v1/feed.
GET /health is the one exception — it sits at the server root, outside /api/v1, so probes are not rate limited.
Authentication
Send the JWT as a bearer token:
Authorization: Bearer <token>A token cookie is also accepted, checked first. Obtain a token from POST /authenticate or POST /create-account; it is valid for 7 days.
Almost everything requires authentication. Every router except authRoutes and webhookRoutes applies the middleware globally:
router.use(authenticated);Public endpoints:
| Endpoint | Notes |
|---|---|
POST /authenticate | Login |
POST /create-account | Register |
POST /forgot-password | Stub |
POST /reset-password | Stub |
POST /check-username | Availability check |
POST /check-username-offensive | Profanity check |
POST /logout | |
POST /webhooks/* | Verified by provider secret instead |
GET /health |
Everything else is authenticated, including /discover, /trending/*, and /search — there is no anonymous browsing.
Rate limiting
300 requests per minute per IP, applied to /api/v1. Exceeding it returns 429.
Effectively global in production
express-rate-limit keys on the client IP, but trust proxy is not configured, so behind nginx-ingress every request appears to come from the ingress pod. The limit is therefore shared across all clients rather than per-client.
Responses
Most success responses return the resource directly, with no envelope:
{ "id": "clx…", "body": "…", "createdAt": "2026-08-14T…" }Two response shapes exist
The trending and search endpoints wrap their payload instead:
{ "success": true, "data": { "users": [], "clips": [], "games": [] } }GET /trending/* and GET /search use the envelope; GET /search/users returns a bare array. Everything else returns the resource directly. Check the per-domain page — or the controller — before writing a client. Converging on one shape would be a worthwhile cleanup, but it is a breaking change for the mobile app.
Errors are always:
{ "message": "Human readable reason" }With errors added for validation failures (zod's flatten() output):
{
"message": "Invalid post payload",
"errors": { "formErrors": [], "fieldErrors": { "body": ["Required"] } }
}In development only, unhandled errors also include error and stack.
APIResponse<T> is not what the API returns
packages/types exports an APIResponse<T> interface with success / data / error fields. Nothing on the server uses it. Do not write clients against it.
Status codes
| Code | Meaning |
|---|---|
200 | Success |
201 | Created |
400 | Validation failed |
401 | Missing, invalid, or revoked token |
403 | Authenticated but not permitted |
404 | Not found |
429 | Rate limited |
500 | Server error |
Thrown HttpErrors respond 500
The error middleware reads the status from err.code, while HttpError sets .status. So throw notFound("…") produces a 500, not a 404. Controllers that need an exact status currently respond directly instead of throwing. See Coding Standards.
Pagination
Cursor-based, not offset-based. The notifications endpoint is the reference implementation:
GET /notifications?limit=20&cursor=<opaque>{
"items": [ … ],
"nextCursor": { "id": "clx…", "createdAt": "2026-08-14T12:00:00.000Z" }
}nextCursor is null on the last page. Page size is clamped — the notifications endpoint caps at 50. The cursor is compound (createdAt plus id) so ordering is stable when timestamps collide.
Not every list endpoint paginates yet. Check the per-domain page.
Content types
application/json for almost everything. multipart/form-data for endpoints that accept files directly:
| Endpoint | Field |
|---|---|
POST /posts | Media files |
POST /user/avatar | Avatar |
POST /user/banner | Banner |
POST /channels, PUT /channels/:channelId | Channel banner |
For anything large, prefer the presigned upload flow — POST /media/upload/url then POST /media/upload/complete — so the bytes never pass through the API. See Media Pipeline.
IDs
All ids are cuid() strings — opaque, URL-safe, roughly sortable by creation time. Never assume they are numeric or a UUID.
Money
Every monetary value in a request or response is an integer number of cents. monthlyPrice: 499 is $4.99. Play Coins are whole units. Never send a float.
Timestamps
ISO 8601 UTC strings: "2026-08-14T12:00:00.000Z".
CORS
Allows a single origin — WEB_APP_URL, defaulting to http://localhost:3000 — with credentials: true. The mobile app is unaffected (native HTTP is not subject to CORS), but a browser client on any other origin will be blocked.
Domain index
| Page | Covers |
|---|---|
| Auth & Accounts | Register, login, password, identity verification, sessions |
| Users & Profiles | Profiles, avatars, follows, settings, blocks, push tokens |
| Content & Feeds | Posts, comments, reactions, feeds, impressions, media |
| Messaging & Channels | DMs, channels, rooms, room messages |
| Live & Sessions | Livestreams, LiveKit tokens, bookings, availability |
| Commerce & Wallet | Products, wallet, subscriptions |
| Discovery & Search | Discover, trending, search, games, sponsors, links |
| Webhooks | RevenueCat, Mux, Stripe |
| Socket Events | The realtime contract |
