Content & Feeds
Posts, comments, reactions, feeds, impressions, and media upload. All endpoints require authentication.
Feeds
| Method | Path | Purpose |
|---|---|---|
GET | /feed | The authenticated user's home feed |
GET | /user/:userId/feed | One user's posts; ?visibility=vip for only their VIP posts (still locked for non-holders) |
Query parameters
| Parameter | Type | Notes |
|---|---|---|
limit | number | Clamped to 1–50, default 20 |
cursor | { id, createdAt } | Page forward |
refreshSince | { id, createdAt } | Fetch only posts newer than this — pull-to-refresh |
excludePostIds | string[] | Posts already on screen, to avoid duplicates |
refreshSince and excludePostIds exist because the feed is an infinite list that also refreshes at the top. Without them, a refresh would either re-render posts already visible or lose position.
Caching
Responses are cached in Redis for 60 seconds, keyed by every parameter:
feed|u:<userId>|limit:20|cur:<id>:<createdAt>|nonew|noskip|v2Invalidated by invalidateUserFeedCache(userId) when the user creates a post or their media finishes processing.
Only the page's ids and cursor are cached — which posts, in what order (the expensive follow-graph query). The rows themselves are read fresh on every request (loadFeedItems() in services/feed.ts), so an edit or delete shows on the next read rather than after the TTL; a post deleted since the page was cached simply drops out. The counts (reactions, reactionCount, commentCount, hiddenCommentCount) and the viewer's own viewerReaction are hydrated fresh by hydratePostStats(), cache hit or not.
Cache invalidation is per-author only
When you post, only your feed cache is cleared. Your followers' caches expire naturally after 60 seconds, so a new post takes up to a minute to appear for them. That is a deliberate trade — fanning out invalidation to every follower would be far more expensive.
Separately, the invalidation uses redis.keys(), which blocks Redis while it scans the entire keyspace. Replace with SCAN before volume grows.
Posts
| Method | Path | Purpose |
|---|---|---|
GET | /posts/:id | One post, in the same shape as a feed item (counts and viewerReaction included) |
POST | /posts | Create a post (multipart/form-data) |
PATCH | /posts/:id | Edit your post (see Editing posts) |
DELETE | /posts/:id | Delete your post (soft delete; see Content Lifecycle) |
GET | /posts/:id/revisions | The post's edit history, newest first, text only |
PUT | /posts/:id/visibility | Move your post to or from VIP: { "visibility": "public" | "vip" } (VIP: PlayPals only) |
POST | /posts/:postId/impression | Record a view |
Post bodies are capped at 280 characters (POST_MAX_CHARS), comments at 2200 (COMMENT_MAX_CHARS), both from @playpals/types and enforced by the API as well as the app.
A deleted post is a 404 everywhere, with code: "POST_DELETED" so the app can say so. Feed items and GET /posts/:id carry editedAt (null until edited), version (number of edits) and hiddenCommentCount.
Editing posts
{
"body": "the whole new text",
"expectedVersion": 0,
"removeMediaIds": ["clx…"],
"altText": { "<mediaId>": "a dog on a skateboard" },
"linkPreviewId": "clx…",
"linkPreviewDismissed": false
}- Author only, within 1 hour of posting and at most 5 edits (
403withEDIT_WINDOW_CLOSED/EDIT_LIMIT). - Media can be removed, never added or swapped, and a photo/video post must keep at least one item (
400 MEDIA_REQUIRED): a post can't become something people didn't react to. Alt text is editable. expectedVersionis the post'sversionthe client edited. If another device edited first the API returns 409VERSION_CONFLICTwith the current post indata.- The previous version is stored as a
PostRevision; mentions and the link card are re-derived from the new body (see Link Previews). A newly tagged user is notified only if no earlier version tagged them. - Viewers get
post:updated. Rate limited to 30 edits per hour per user.
POST /posts
Accepts multipart/form-data with files plus these fields:
{
body?: string, // required for "text"; optional caption for "clip"/"photos"
type?: "text" | "clip" | "photos", // default "text"
visibility?: "public" | "private" | "followers" | "vip", // "vip": PlayPals only (403 VIP_PLAYPAL_ONLY)
commentable?: boolean, // default true
gameId?: string,
linkPreviewId?: string, // the composer's preview for the first link
linkPreviewDismissed?: boolean, // the author removed the card
}VIP posts
A post with visibility: "vip" is for its author's subscribers (VIP pass holders, see Monetization). Every read decides per viewer (presentPostsFor, apps/api/src/services/postAccess.ts):
- Author and subscribers get the whole post. Its media is private storage, so every URL (
originalUrl, variants,posterUrl) is a signed Spaces URL valid for 10 minutes, and a video carries a signedhlsUrl(Mux JWT) withmuxPlaybackId: null. Clients refetch for fresh URLs and cache images by object path. - Everyone else gets it locked:
locked: true,body: "", no mentions or link card, and each media item reduced to its shape and thumbhash (no URLs). Profiles show locked VIP posts; the home feed leaves them out; trending and search never include them. - Comments (reading and writing), reactions, tips, revisions and the post's live room answer
403 VIP_ONLYto non-subscribers. Impressions from them are ignored. - A VIP post shared in a DM is always sent locked (one payload reaches both people); opening it fetches the post. Only someone who can see it can share it.
- Mention notifications for a VIP post carry no text preview, and PlayPalz's own URLs are never unfurled into link cards.
PUT /posts/:id/visibility changes who sees it at once; ogun's media-visibility job then makes the files match (private ACL, CDN purge and a signed Mux playback id for VIP; the reverse otherwise). Until that job runs, a post moving to VIP keeps the public URLs already handed out.
Photo and video posts don't need a caption. A missing or whitespace-only body is stored as "". A text post with an empty body is rejected with 400 unless files are attached in the same request. The schema is createPostSchema in apps/api/src/schemas/post.ts. The mobile client creates the post before uploading its media, so for it type is the only sign that media will follow.
Files stream to Spaces via multer-s3, a Media row is created per file with status: "pending", and a job is queued for each. The post is returned immediately; media becomes available as ogun finishes. See Media Pipeline.
@username tags in body are resolved to Mention rows before the response returns, so the post is never fetched without them. A tag is letters, digits, _, . and -, starting and ending on a letter, digit or _ (so thanks @john.doe. tags john.doe). An @ straight after a word character, as in an email address, is not a tag. The tokenizer is findMentions in @playpals/types, shared with the app's PostText. Username lookup is case-insensitive.
The first link in a post without media gets a preview card: the composer's linkPreviewId is reused if it matches, otherwise the server resolves one, waiting at most ~2s. Posts are returned with linkPreview (public fields only), rendered once its status is "ready". See Link Previews.
type matters beyond display — ogun reads the parent post's type to decide which image variants to generate.
Link previews
| Method | Path | Purpose |
|---|---|---|
POST | /link-previews | Resolve the preview for { url } (cached; unfurls on a miss) |
GET | /link-previews/:id | One preview, polled while its image is processed |
POST /link-previews
{ "url": "https://example.com/article" }
→ { "preview": { "id", "url", "displayDomain", "title", "description", "siteName",
"cardType", "status", "imageStatus", "imageWidth", "imageHeight",
"imagePlaceholder", "imageFocalX", "imageFocalY", "imageVariants" } }
→ { "preview": null, "reason": "blocked" | "timeout" | "bad_status" | "rate_limited" | … }Only http(s) URLs up to 2048 characters are accepted. Rate limited to 60 per user per 10 minutes. A null preview means the link stays a plain link.
Comments
| Method | Path | Purpose |
|---|---|---|
GET | /posts/:id/comments | Top-level comments, newest first |
GET | /posts/:id/comments/:commentId/replies | One thread's replies, oldest first |
POST | /posts/:id/comments | Add a comment or reply ({ body, parentId? }) |
PATCH | /posts/:id/comments/:commentId | Edit your comment ({ body, expectedVersion }) |
DELETE | /posts/:id/comments/:commentId | Delete a comment (yours, or any on your post) |
PUT | /posts/:id/comments/:commentId/hidden | Hide a comment on your post |
DELETE | /posts/:id/comments/:commentId/hidden | Unhide it |
GET | /posts/:id/comments/hidden | The post's "Hidden comments" list, for anyone who can see the post |
Both list endpoints take limit (1–50, default 20) plus cursorId and cursorCreatedAt, skip authors the viewer is blocked with, and return items shaped like this:
{
id, postId, parentId, body, createdAt, user, mentions,
likeCount: number,
replyCount: number, // replies the viewer can see
viewerLiked: boolean,
editedAt: string | null,
version: number,
deleted: boolean, // a placeholder: user is null, body ""
hidden: boolean, // only ever true for the post's author
replyingTo?: string, // hidden list only: the parent's author
}Hidden comments are left out of both lists for everyone except their own author, who sees theirs as normal. They don't count toward commentCount or replyCount.
Replies
Threads are one level deep. parentId may name any comment on the same post; replying to a reply stores the new comment under that reply's top-level parent. A parentId from another post is a 400.
A reply notifies the thread's author (COMMENT_REPLY) and the post author (COMMENT), once each and never the replier. Both notifications carry target.commentId (the thread's top-level comment), so the app opens the post on that thread with its replies expanded. @user mentions still send MENTION.
Editing
Author only, within 1 hour, same validation as a new comment. The post's author can't edit someone else's comment. Version-checked like posts (409 VERSION_CONFLICT). The previous text is kept as a CommentRevision (for audit; the app shows only "Edited"), mentions are re-synced, and the stored preview text of the comment's notifications is updated. Viewers get post:comment:updated.
Deleting
The comment's author or the post's author may delete (403 otherwise). It is a soft delete:
- A reply, or a top-level comment with no live replies, disappears.
- A top-level comment with live replies stays as a placeholder (
deleted: true, no author or text) so other people's replies survive; it disappears once its last reply does. - Notifications about the comment are removed. Rows are purged after 30 days (see Content Lifecycle).
Hiding
The post's author can hide other people's comments on their post (not their own). Hiding moves the comment, and for a top-level comment its whole thread, out of the default list into "Hidden comments", which anyone can open. The commenter isn't notified and still sees their comment in place. Idempotent; the realtime events skip the commenter.
Reactions
| Method | Path | Purpose |
|---|---|---|
POST | /reactions | React ({ kind, postId } or { kind, commentId }) |
DELETE | /reactions/:contentId?contentType=post|comment | Remove your reaction from that post or comment |
Reactions are polymorphic — the payload targets exactly one of a post or a comment — and kind must be one of like, love, haha, wow, sad, angry.
A user has at most one reaction per target: POST replaces whatever they had, so a repeated tap never stacks. DELETE is idempotent. Note that @@unique([userId, postId, commentId, kind]) does not enforce this — one of postId/commentId is always NULL, and Postgres treats NULLs as distinct — so the service does it inside a transaction.
Live updates
Everyone with a post on screen receives its new comments, replies, deletions, and reaction counts over the post:<postId> socket room. See Socket Events.
Impressions
POST /posts/:postId/impression
{ "viewDurationMs": 3400, "videoWatchMs": 2100 }
→ 204 No ContentPostImpression is unique per (userId, postId), so the endpoint upserts — repeat calls accumulate engagement on one row rather than inflating a view count. videoWatchMs is only meaningful for clip posts.
Media upload
The preferred path, avoiding the API entirely for the bytes:
1. Request a presigned URL
POST /media/upload/url
{ "mimeType": "image/jpeg" }Only JPEG, PNG, WebP, GIF, HEIC, MP4 and MOV are signed (400 UNSUPPORTED_FILE_TYPE otherwise), and the key's extension comes from that list, not from the client.
{
"key": "uploads/<userId>/images/2026/<uuid>.jpeg",
"url": "https://…presigned…",
"expiresIn": 300
}2. PUT the file to url
Directly to Spaces. Nothing goes through the API.
3. Notify completion
POST /media/upload/complete
{ "key": "uploads/…", "postId": "clx…", "mimeType": "image/jpeg", "width": 1080, "height": 1350, "order": 0 }
→ { "ok": true }Before anything is registered, the API checks the stored object (HeadObject). If it is over 10 MB (images) or 2 GB (video), or was stored with a different Content-Type, the object is deleted and the call answers 413 FILE_TOO_LARGE or 400 UNSUPPORTED_FILE_TYPE. A presigned PUT can't enforce a size on its own from React Native, so this is where the cap lives.
This creates the Media row, sets the object ACL to public-read, enqueues processing, and invalidates the user's feed cache. The key must be under the caller's own uploads/<userId>/ prefix, the post must be theirs and still pending, and a post holds at most 5 photos or one video. order is the file's position in the composer. Registering the same key twice is a no-op.
Presigned URLs expire after 5 minutes.
Reporting
POST /reports files a report against a post, comment, or user. Reasons come from the shared REPORT_REASONS list, and a repeat report of the same target is idempotent while the first is still open. See Moderation for the payload and rate limits.
Reports are stored but nothing reviews them — there is no moderation pipeline or admin surface consuming them, and the moderationStatus columns on Post and Comment are still unused. Partial
