Skip to content

Content & Feeds ​

Posts, comments, reactions, feeds, impressions, and media upload. All endpoints require authentication.

Feeds ​

MethodPathPurpose
GET/feedThe authenticated user's home feed
GET/user/:userId/feedOne user's posts; ?visibility=vip for only their VIP posts (still locked for non-holders)

Query parameters ​

ParameterTypeNotes
limitnumberClamped to 1–50, default 20
cursor{ id, createdAt }Page forward
refreshSince{ id, createdAt }Fetch only posts newer than this — pull-to-refresh
excludePostIdsstring[]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|v2

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

MethodPathPurpose
GET/posts/:idOne post, in the same shape as a feed item (counts and viewerReaction included)
POST/postsCreate a post (multipart/form-data)
PATCH/posts/:idEdit your post (see Editing posts)
DELETE/posts/:idDelete your post (soft delete; see Content Lifecycle)
GET/posts/:id/revisionsThe post's edit history, newest first, text only
PUT/posts/:id/visibilityMove your post to or from VIP: { "visibility": "public" | "vip" } (VIP: PlayPals only)
POST/posts/:postId/impressionRecord 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 ​

json
{
  "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 (403 with EDIT_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.
  • expectedVersion is the post's version the client edited. If another device edited first the API returns 409 VERSION_CONFLICT with the current post in data.
  • 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:

ts
{
  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 signed hlsUrl (Mux JWT) with muxPlaybackId: 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_ONLY to 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.

MethodPathPurpose
POST/link-previewsResolve the preview for { url } (cached; unfurls on a miss)
GET/link-previews/:idOne preview, polled while its image is processed
http
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 ​

MethodPathPurpose
GET/posts/:id/commentsTop-level comments, newest first
GET/posts/:id/comments/:commentId/repliesOne thread's replies, oldest first
POST/posts/:id/commentsAdd a comment or reply ({ body, parentId? })
PATCH/posts/:id/comments/:commentIdEdit your comment ({ body, expectedVersion })
DELETE/posts/:id/comments/:commentIdDelete a comment (yours, or any on your post)
PUT/posts/:id/comments/:commentId/hiddenHide a comment on your post
DELETE/posts/:id/comments/:commentId/hiddenUnhide it
GET/posts/:id/comments/hiddenThe 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:

ts
{
  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 ​

MethodPathPurpose
POST/reactionsReact ({ kind, postId } or { kind, commentId })
DELETE/reactions/:contentId?contentType=post|commentRemove 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 ​

http
POST /posts/:postId/impression
{ "viewDurationMs": 3400, "videoWatchMs": 2100 }
→ 204 No Content

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

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

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

http
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

Internal documentation — PlayPalz platform