Skip to content

API Conventions ​

Read this once; the per-domain pages assume it.

Base URL ​

EnvironmentBase URL
Productionhttps://api.playpalz.gg/api/v1
Localhttp://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:

http
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:

ts
router.use(authenticated);

Public endpoints:

EndpointNotes
POST /authenticateLogin
POST /create-accountRegister
POST /forgot-passwordEmail a password-reset code
POST /reset-passwordReset the password with the code
POST /check-usernameAvailability check
POST /check-username-offensiveProfanity 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:

json
{ "id": "clx…", "body": "…", "createdAt": "2026-08-14T…" }

Two response shapes exist

The trending and search endpoints wrap their payload instead:

json
{ "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:

json
{ "message": "Human readable reason" }

With errors added for validation failures (zod's flatten() output):

json
{
  "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 ​

CodeMeaning
200Success
201Created
400Validation failed
401Missing, invalid, or revoked token
403Authenticated but not permitted
404Not found
409Conflict (e.g. an email that already has an account)
413Upload too large
429Rate limited
500Server error
501Not 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:

http
GET /notifications?limit=20&cursor=<opaque>
json
{
  "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:

EndpointField
POST /postsMedia files
POST /user/avatarAvatar
POST /user/bannerBanner
POST /channels, PUT /channels/:channelIdChannel 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 ​

PageCovers
Auth & AccountsRegister, login, password, identity verification, sessions
Users & ProfilesProfiles, avatars, follows, settings, blocks, push tokens
Content & FeedsPosts, comments, reactions, feeds, impressions, media
Messaging & ChannelsDMs, channels, rooms, room messages
Live & SessionsLivestreams, LiveKit tokens, bookings, availability
Commerce & WalletProducts, wallet, subscriptions
Discovery & SearchDiscover, trending, search, games, sponsors, links
WebhooksRevenueCat, Mux, Stripe
Socket EventsThe realtime contract

Internal documentation — PlayPalz platform