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 | Email a password-reset code |
POST /reset-password | Reset the password with the code |
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. Some routes add a tighter limiter of their own (reports, the waitlist, and the auth endpoints).
The API sets trust proxy to 1 (the nginx-ingress hop), so req.ip is the client's address from X-Forwarded-For rather than the ingress pod's. If another proxy is ever put in front of the ingress, raise the hop count to match, or every client will share one limit again.
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"] } }
}A ZodError passed to next() (any controller calling schema.parse()) becomes a 400 whose message is the first issue's message. A 5xx that isn't one of our own HttpErrors always says "Internal Server Error": the real message (Prisma's names tables and columns) goes only to the log. 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 |
409 | Conflict (e.g. an email that already has an account) |
413 | Upload too large |
429 | Rate limited |
500 | Server error |
501 | Not implemented yet |
Throw an HttpError (badRequest, notFound, conflict…) and the error middleware responds with its status, message and optional code. 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 only the exact origins in WEB_APP_URL (comma-separated; prod is https://www.playpalz.gg) with credentials: true. Outside development and test the API refuses to start without it, rather than falling back to http://localhost:3000. The mobile app is unaffected (native HTTP is not subject to CORS), but a browser client on any other origin will be blocked.
Security headers
helmet with explicit settings in apps/api/src/app.ts. The API only returns JSON, so the CSP is default-src 'none'; frame-ancestors 'none', and HSTS is max-age=31536000; includeSubDomains (set by the API as well as the ingress, in case a proxy drops it). The rest are helmet's defaults (nosniff, no-referrer, X-Frame-Options, and so on).
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 |
