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-passwordStub
POST /reset-passwordStub
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.

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:

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"] } }
}

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
429Rate limited
500Server 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:

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

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