Users & Profiles
apps/api/src/router/userRoutes.ts and followRoutes.ts. Every endpoint requires authentication.
Current user
| Method | Path | Purpose |
|---|---|---|
GET | /user | The authenticated user |
GET | /me | Alias for /user |
PUT | /user | Update profile fields |
POST | /user/avatar | Upload avatar (multipart/form-data) |
POST | /user/banner | Upload banner (multipart/form-data) |
Both aliases exist because the mobile app and the web app were written against different names. Prefer /me. Every endpoint here that returns your own user strips the password hash and internal fields with toSelfUser (see Auth).
Avatar, banner and channel banner uploads accept any image up to 10 MB. They are re-encoded to JPEG with Sharp (apps/api/src/lib/upload.ts), which removes EXIF and GPS data and rejects anything that isn't really an image (400). Oversized files answer 413. Legacy multipart post media accepts only JPEG, PNG, WebP, GIF, HEIC, MP4 and MOV, up to 200 MB.
PUT /user accepts only username, display_name, first_name, last_name (each ≤ 50 characters), gender (≤ 30), bio (≤ 500), birthdate, timezone, dmPolicy and onboarded: true. Anything else, including email and avatar, is dropped. Email changes need a verified flow that doesn't exist yet, and avatars go through /user/avatar. onboarded can only be set to true, and only once the user has a username and a birthdate (USERNAME_REQUIRED, BIRTHDATE_REQUIRED). A username, here or on PUT /profile/username, must not contain an offensive word (400 USERNAME_NOT_ALLOWED) or belong to anyone else in any casing (409 USERNAME_TAKEN).
dmPolicy is "Who can message you": everyone (default), following or nobody. See Messaging → Who can message you.
PUT /user validates birthdate: a real date, not in the future, and at least 13 years ago (BIRTHDATE_INVALID, BIRTHDATE_TOO_YOUNG). It sets coin limits for under-18s, so once set it can't be changed from the app: a different date is 409 BIRTHDATE_LOCKED (the same date is accepted). Corrections go through support.
Profiles
| Method | Path | Purpose |
|---|---|---|
GET | /profile | Your own full profile |
GET | /profile/:id | Another user's profile |
PUT | /profile/username | Change username |
PUT | /profile/display-name | Change display name |
PUT | /profile/bio | Change bio |
PUT | /profile/price | Change monthly subscription price (cents) |
Usernames must match USERNAME_REGEX from @playpals/types: 3–20 characters, letters, numbers and underscores only. No spaces, because a username with a space can never be @mentioned. Both PUT /profile/username and the onboarding profile save (username on the user update) return 400 for anything else. The mobile onboarding and edit screens check the same rule and strip spaces as they're typed. Existing usernames that break the rule keep working until they're changed.
Each profile field has its own endpoint rather than one PATCH, which mirrors the mobile app's one-field-per-screen editing flow (app/(app)/(tabs)/me/edit/).
PUT /profile/price sets User.monthlyPrice, the amount fans pay for a creator subscription and the figure anansi multiplies by the active subscriber count at payout time.
Following
| Method | Path | Purpose |
|---|---|---|
POST | /user/:id/follow | Follow a user |
POST | /user/:id/unfollow | Unfollow |
GET | /user/following | People you follow (paged, searchable) |
GET | /user/:id/followers | People who follow a user |
GET | /user/:id/following | People a user follows |
GET /user/following?cursor=&limit=&q= returns { items: [{ id, username, display_name, avatar }], nextCursor }:
- Always the signed-in user's own follows, most recently followed first.
limitdefaults to 30, max 50. Pass the previousnextCursorto get the next page.qmatches username or display name, ignoring case.- Blocked users, in either direction, are left out.
It feeds the share-to-DM sheet (components/post/share-sheet.tsx), which lists recent DM threads and then everyone you follow.
GET /user/:id/followers and GET /user/:id/following take the same cursor, limit and q and return the same shape. Each item also has isFollowing, which says whether you (the viewer) follow that person; the list's Follow button uses it.
- People blocked with you, in either direction, are left out.
- A profile that is blocked with you returns an empty page, matching the profile itself hiding its contents.
- The app opens these from the Following / Followers counts on a profile (
app/(app)/follows/[userId].tsx).
In the profile responses (GET /profile, GET /profile/:id), followerCount is the number of people who follow the user and followingCount is the number of people they follow.
GET /profile/:id never includes the other user's birthdate. A suspended or deleted account's profile answers 404.
Follow has @@unique([followerId, followingId]), so a duplicate follow cannot create a second row. Following triggers a NEW_FOLLOWER notification.
Security & sessions
| Method | Path | Purpose |
|---|---|---|
GET | /user/sessions | Active login sessions |
GET | /user/login-history | Historical logins |
POST | /auth/logout-all | Revoke every token |
PUT | /auth/change-password | Change password |
DELETE | /user/account | Delete the account |
/user/sessions and /user/login-history both read AuthSession rows — device name, IP, user agent, timestamp. Remember that deactivating a session row does not revoke anything on its own; only /auth/logout-all does. See Authentication.
DELETE /user/account needs the current password in the body ({ "password": "…" }). A missing or wrong one answers 400 (PASSWORD_REQUIRED, PASSWORD_INCORRECT), so a borrowed or stolen phone with a live session can't delete the account.
It has to unwind relations explicitly, because most foreign keys restrict rather than cascade. Verify what it actually removes before treating it as a GDPR erasure path.
A user with any coin history is a financial record, so their row is anonymized (as admin deletion does) instead of deleted. Their spend-only coins are forfeited to FORFEITED, and their payouts and ledger rows are kept. While they hold PlayPal earnings that can still be cashed out, deletion is refused with 409 and code CASHABLE_BALANCE. Users with no coin history are deleted as before.
Blocking
| Method | Path | Purpose |
|---|---|---|
GET | /user/blocked | List blocked users |
POST | /user/block/:userId | Block |
DELETE | /user/block/:userId | Unblock |
Blocks are stored in the Block model with a unique constraint on the pair, and are symmetric for visibility — if either party blocked the other, neither sees the other's content. Blocking also removes follows in both directions and clears notifications between the pair; it does not delete posts, comments, or messages, which are filtered at read time so unblocking restores them.
Feed, profile, search, discover, trending, comments, notifications, and DM queries all filter against it. Moderation lists each surface and the two traps (global trending caches, and feed-cache invalidation).
Notification preferences
| Method | Path | Purpose |
|---|---|---|
GET | /user/notifications | Read preferences |
PUT | /user/notifications | Update preferences |
Note the collision with the notification inbox at /notifications — /user/notifications is preferences, /notifications is the list. Easy to confuse. Fields are documented in Notifications.
Push tokens
| Method | Path | Purpose |
|---|---|---|
POST | /user/push-token | Register an Expo push token |
DELETE | /user/push-token | Remove one |
Tokens are validated with Expo.isExpoPushToken before use, and automatically removed when Expo reports DeviceNotRegistered.
Games
| Method | Path | Purpose |
|---|---|---|
GET | /games | The game catalog |
PUT | /user/games | Set favourite games |
The catalog is populated by igdb-heartbeat. Favourites are chosen during onboarding and feed into discovery.
Verification
Creator identity verification and payout setup have their own page: Creator Onboarding. The old POST /verification-asset stub, which never responded, has been removed.
Face enrollment
| Method | Path | Purpose |
|---|---|---|
GET | /biometrics/face | Enrollment status |
POST | /biometrics/face | Enroll or replace the reference selfie (multipart selfie, consent=true, consentVersion) |
DELETE | /biometrics/face | Withdraw consent and delete the selfie |
Biometric data with its own consent and retention rules. See Biometrics & Face Enrollment. The 2FA check itself (POST /verify-identity) is still a stub; see Auth & Accounts.
