Mobile Posts — Feed & Detail Data Structure
For mobile rendering. This explains every field the API returns on a post — in the feed, in a filtered list, and on a single post-detail screen — so you can decide what to show and which buttons (CTAs / actions) to enable based on who is looking: the post's author, or a third person (any other viewer).
This is a read guide. It does not tell you how to create posts (see
mobile-posts-guide.md) or the request/DM flow mechanics (seemobile-post-requests-guide.mdandmobile-post-request-dm-guide.md). It tells you what the JSON means.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every endpoint here. - Send
X-Client-Type: mobileon every request.
1 — One post shape, everywhere
Every endpoint that returns posts runs them through one serializer, so the post object looks the same whether it came from the feed, a profile, saved bookmarks, or a single-post fetch. Learn the shape once.
Endpoints that return the list shape (array of posts):
GET /api/posts → filtered list { data: { items, nextCursor } }
GET /api/posts/me → my posts
GET /api/posts/user/:username → someone's posts
GET /api/posts/saved → my bookmarks
GET /api/feed → personalized feed
Endpoints that return the single shape (one post):
GET /api/posts/:id → one post { data: <post> }
POST /api/posts → after create { data: <post> }
POST /api/posts/:id/repost → after repost { data: <post> }
Response envelope is always { success: true, data: ... }. List endpoints wrap
posts as { items: [<post>, ...], nextCursor: string | null }.
The only difference between the two shapes:
| Field | List / feed | Single detail (GET /:id) |
|---|---|---|
comments | ❌ not sent | ✅ full comment array sent |
Everything else is identical. So the feed gives you a comment count
(_count.comments) but not the comments themselves; open the detail screen to
get the actual comment list.
2 — The two roles
Several fields change value depending on the viewer. There are exactly two
roles, decided by comparing the logged-in user's id to authorId:
- Author —
authorId === your user id. This is your own post. - Third person — everyone else. Any other logged-in viewer.
The server already resolves role-dependent fields for you (cta.buttonLabel,
_count.requests, viewerRequestStatus, …). You do not re-compute them —
you read them and render. The role comparison is only useful so you understand
why a value looks the way it does, and to pick which action buttons to show.
Quick rule:
const isAuthor = post.authorId === currentUser.id;
3 — Full field reference
Every key on a post object. Fields are grouped for reading; the JSON is flat (except nested objects noted below).
3.1 — Core content (same for everyone)
| Field | Type | Meaning |
|---|---|---|
id | string | The post's unique id. |
type | string enum | Post kind: FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING, SHARE_OPPORTUNITY, ANYTHING, REPOST. Drives which optional fields are filled and whether a CTA exists. |
title | string | null | Post title. null on kinds that don't use one (e.g. plain ANYTHING, REPOST). |
content | string | The body text. Can be "" (empty) for media-only ANYTHING posts and always empty for simple reposts. |
location | string | null | Free-text location. null if unset. |
locationMode | string | null | CITY | SCHOOL | ONLINE | HYBRID. Only Start-Something sets this. null otherwise. |
eventAt | ISO date-time | null | When the thing happens. Set on Find-Teammates / Invite / Opportunity. null if none. |
deadlineAt | ISO date-time | null | Request cut-off. Required on Find-Teammates / Invite-People (pre-existing rows may still be null). |
link | string | null | External URL (Share-Opportunity / Start-Something). null if none. |
details | object | null | Free-form escape-hatch JSON for future per-kind fields. Usually null. |
isHidden | boolean | Author hid the post. Hidden posts don't reach third-person viewers at all (they 404), so you'll normally see this false. |
createdAt | ISO date-time | When posted. |
updatedAt | ISO date-time | Last edit time. |
authorId | string | The author's user id. Compare with the current user to get the role. |
3.2 — Author block
author is a nested object describing who posted it. Same for every viewer.
| Field | Type | Meaning |
|---|---|---|
author.id | string | Author user id (same as authorId). |
author.username | string | Handle. |
author.grade | string | null | Author's current grade (e.g. "5th"). null if unset. |
author.school | string | null | Author's current school name. null if unset. |
author.profile | object | null | Profile card. null if the user has no profile row. |
author.profile.displayName | string | Shown name. Prefer this over username for display. |
author.profile.avatarUrl | string | null | Avatar image URL. null → render initials/placeholder. |
3.3 — Topics & media (same for everyone)
topics — array of tag objects attached to the post:
| Field | Type | Meaning |
|---|---|---|
id | string | Topic id. |
slug | string | URL-safe key. |
name | string | Display label. |
icon | string | null | Icon name. null if none. |
media — array of image/video rows (empty array if none):
| Field | Type | Meaning |
|---|---|---|
id | string | Media id. |
type | string | image | video | gif. |
url | string | The full-resolution original. Can be several MB — do not load this in the feed (see 3.3.1). |
width | number | null | Pixel width of the original. Often null — prefer a variant's width for layout (see 3.3.1). |
height | number | null | Pixel height of the original. Same caveat as width. |
altText | string | null | Accessibility text. null if none. |
processingStatus | string | pending | processing | complete | failed. Governs whether variants is populated (see 3.3.1). |
variants | array | Resized renditions (thumbnail/small/medium/large). Empty until processingStatus === "complete". |
3.3.1 — Image variants & which URL to render
Rule: never render media.url in the feed. url is the untouched original
the user uploaded — routinely 3–5 MB. Loading many of those in a scrolling list
causes intermittent decode/memory failures (blank images that "fix themselves"
when you open the detail screen, then blank again on the way back). Render a
variant instead; keep url for the detail/full-screen view only.
Each processed image exposes up to four variants. variants is ordered
alphabetically by variantType (large, medium, small, thumbnail) — look up by
variantType, never by array index.
Each variant object:
| Field | Type | Meaning |
|---|---|---|
variantType | string | thumbnail | small | medium | large. |
url | string | Render this. WebP. |
width | number | Actual pixel width of the variant. |
height | number | Actual pixel height of the variant. |
sizeBytes | number | Byte size — always small (KB range). |
| variantType | Size | Use for |
|---|---|---|
thumbnail | 240×240 (square crop) | Grid tiles, avatars-of-media, dense multi-image collages. |
small | ≤750px wide | Feed card default. |
medium | ≤1080px wide | Single-image feed card on large phones / tablets. |
large | ≤1440px wide | Post-detail screen. |
| (original) | media.url | Full-screen / pinch-to-zoom only. |
Videos (type: "video") get only a thumbnail variant (a 240×240 poster
frame). Play media.url when the user taps.
Picking the URL (pseudocode):
function feedImageUrl(m):
v = m.variants.find(x => x.variantType === "small")
?? m.variants.find(x => x.variantType === "medium")
?? m.variants.find(x => x.variantType === "thumbnail")
return v?.url ?? m.url // fall back to original only if no variants yet
Dimensions for aspect-ratio boxing: take width/height from the variant
you chose, not from top-level media.width/media.height — those are often
null even after processing. This lets you reserve the correct box and avoid
layout jump. If you fell all the way back to media.url and its dims are null,
use a sensible default ratio (e.g. 4:5) until the image loads.
While processingStatus !== "complete": variants is empty. Two options —
(a) show a placeholder/skeleton until a later fetch returns variants, or (b)
render media.url directly as a stopgap (works, just heavy). The background
processor normally fills variants within seconds of upload; a failed status
means it never will (retry/report, don't spin forever).
3.4 — Counts
_count — a small object of totals:
| Field | Type | Meaning |
|---|---|---|
likes | number | Total likes. |
comments | number | Total comments (top-level + replies). |
reposts | number | Total reposts of this post. |
shares | number | Total times shared. |
requests | number | Role-dependent. Pending request count. Shows the real number only to the author; a third person always sees 0. Use it to render the author's "N new requests" badge. |
3.5 — Viewer state (this logged-in user's private relationship to the post)
These describe your relationship to the post. For a third person they reflect that viewer's own taps; for the author, some don't apply (see notes).
| Field | Type | Meaning |
|---|---|---|
isLiked | boolean | Did this viewer like it. Drives the filled/empty heart. |
isSaved | boolean | Did this viewer bookmark it. Private — never shown to others. Drives the filled/empty bookmark. |
interestStatus | string | null | This viewer's recommendation feedback: "interested", "not_interested", or null (none). Not settable on your own post. |
viewerRequestStatus | string | null | This viewer's request state on the post: PENDING, ACCEPTED, REJECTED, WITHDRAWN, or null (never asked). Always null for the author (you don't request your own post). |
viewerRequestConversationId | string | null | The DM opened when this viewer's request was accepted. This is the navigation target for the "Open chat" button. null unless viewerRequestStatus === "ACCEPTED" and a DM was successfully linked. |
3.6 — Request prompt block (only meaningful on CTA kinds)
These come straight off the post. They describe the little questionnaire a third person fills when they tap the CTA. On CTA-disabled kinds they are empty.
| Field | Type | Meaning |
|---|---|---|
requestPrompt | string | null | The question shown to a requester, e.g. "Why are you a good fit?". |
requestPromptDescription | string | null | Helper line under the prompt. |
requestOptions | string[] | The multiple-choice chips a requester can pick from. Empty on non-CTA kinds. |
requestPromptSource | string | null | Where the prompt came from: DEFAULT (catalog default), AUTHOR (author customized), AI, or null. Informational only. |
requestState | string | Author's intake gate: OPEN (accepting), PAUSED (temporarily off), COMPLETED (permanently closed). Drives the CTA label for fresh viewers — see §4. |
3.7 — CTA object (role-resolved — read §4)
cta — the single most important object for buttons. It is pre-resolved for
the current viewer. See the next section for the full breakdown.
3.8 — Repost fields
Present on every post; only interesting for reposts or posts that have been reposted.
| Field | Type | Meaning |
|---|---|---|
originalPostId | string | null | On a type: REPOST row, points at the original post. null + type: REPOST together mean the original was deleted → render "Original removed". null on a normal post. |
original | object | null | Embedded summary of the reposted original (so you can render the quoted card without a second fetch). null if not a repost, or original deleted. Shape: { id, type, title, content, createdAt, author, media }. |
isReposted | boolean | Did this viewer already repost this post (one-tap simple repost). Drives the filled/active repost icon. |
viewerRepostId | string | null | The id of this viewer's own repost row. Pass it (or the original id) to DELETE /api/posts/:id/repost to undo. null if not reposted. |
repostedBy | object | null | Social proof: mutuals of this viewer who reposted it. null if none. Shape: { users: [{ id, username, profile }], mutualCount } — up to 3 named users, newest first, plus the total mutual count. May lag follow changes by up to 5 min. |
3.9 — Comments (single-detail only)
Only present on GET /api/posts/:id. Array of comments, each with its
author block (id, username, profile), ordered oldest-first. In the
feed you get only _count.comments.
4 — The cta object (author vs third person)
This is where role-based rendering lives. The server has already picked the right label and enabled/disabled state for the current viewer. You render what it says — no per-role branching needed for the label itself.
4.1 — Fields
| Field | Type | Meaning |
|---|---|---|
enabled | boolean | Does this post kind have a request CTA at all. false for SHARE_OPPORTUNITY, ANYTHING, REPOST. When false, show no request button. |
verb | string | The action verb: join / attend / collaborate. Used in copy. |
buttonLabel | string | The label to show on the button, already resolved for this viewer. This is the field you render. |
actionable | boolean | Whether the button should be tappable. false → render it disabled/informational (e.g. "Requested", "Closed", "Approved"). |
pendingLabel | string | Raw label for the pending state (the resolver may have already put it in buttonLabel). |
acceptedLabel | string | Raw label for accepted state. |
rejectedLabel | string | Raw label for rejected state. |
authorLabel | string | Raw label the author sees ("View Responses"). |
pausedLabel | string | Raw label for a fresh viewer when intake is paused. |
completedLabel | string | Raw label for a fresh viewer when intake is closed. |
You mostly only need
enabled,buttonLabel, andactionable. The raw per-state labels are there if you want to build your own copy.
4.2 — How buttonLabel + actionable are resolved
The server walks these rules in order and stops at the first match:
| Viewer situation | buttonLabel becomes | actionable | What to render |
|---|---|---|---|
CTA disabled (enabled: false) | "" | false | No request button at all. |
| Author | authorLabel → "View Responses" | true | Tap → open the requests inbox for this post. |
Third person, viewerRequestStatus = PENDING | pendingLabel → "Requested" | true | Already asked; can re-submit/withdraw. |
Third person, viewerRequestStatus = ACCEPTED | acceptedLabel → "Open chat" | false | Tap → open DM at viewerRequestConversationId. |
Third person, viewerRequestStatus = REJECTED | rejectedLabel → "Request Declined" | false | Disabled; cannot re-ask. |
Third person, fresh, requestState = PAUSED | pausedLabel → "Requests Paused" | false | Disabled. |
Third person, fresh, requestState = COMPLETED | completedLabel → "Closed" | false | Disabled. |
Third person, fresh, requestState = OPEN | buttonLabel → "Request to Join/Attend/Collaborate" | true | Tap → open the request form. |
Note the exception: an ACCEPTED viewer's
buttonLabelis "Open chat" butactionableisfalse— "not actionable" means "cannot submit a request", not "cannot tap". When the label is "Open chat", navigate toviewerRequestConversationId. Treat "Open chat" as the one special tap on an otherwise non-request button.
4.3 — Simplest rendering logic
if (!post.cta.enabled) {
// no request CTA for this kind — skip the button
} else if (isAuthor) {
// button: post.cta.buttonLabel ("View Responses")
// badge: post._count.requests (pending count, author-only)
// tap → GET /api/posts/:id/requests
} else if (post.viewerRequestStatus === "ACCEPTED") {
// button: "Open chat" — tap → open post.viewerRequestConversationId
} else {
// button: post.cta.buttonLabel
// enabled: post.cta.actionable
// tap (if actionable) → open the request form
}
5 — Per-kind cheat sheet
Which fields are populated and whether a CTA exists, by type.
Kind (type) | Has CTA? | verb | Typical filled fields |
|---|---|---|---|
FIND_TEAMMATES | ✅ yes | join | title, content, topics, eventAt, deadlineAt, (opt) media/location |
INVITE_PEOPLE | ✅ yes | attend | title, content, topics, location, eventAt, deadlineAt, (opt) media/link |
START_SOMETHING | ✅ yes | collaborate | title, content, topics, (opt) media/location/locationMode/link |
SHARE_OPPORTUNITY | ❌ no | — | title, content, topics, location, eventAt, (opt) media/link |
ANYTHING | ❌ no | — | topics, then content or media (one required); (opt) location |
REPOST | ❌ no | — | empty title/content; originalPostId + original embed carry the quoted post |
- CTA-enabled kinds (
FIND_TEAMMATES,INVITE_PEOPLE,START_SOMETHING) are the only ones with a realcta,requestPrompt*,requestOptions, and a meaningfulrequestState. Everything else hascta.enabled: falseand empty request fields. REPOST: render the reposter (author) as "X reposted", then render theoriginalcard underneath. IforiginalisnullandtypeisREPOST, show "Original removed".
6 — Which actions to show (by role)
Data tells you role and state; here's the button set each role gets. All are separate endpoints — this guide only says when to show them.
Author (your own post):
- Edit →
PATCH /api/posts/:id - Delete →
DELETE /api/posts/:id - View responses →
GET /api/posts/:id/requests(badge from_count.requests) - Pause / Resume / Complete intake →
PATCH /api/posts/:id/request-state(only on CTA kinds; reflect currentrequestState) - Accept / Reject / Reopen a request →
PATCH /api/posts/:id/requests/:reqId - Like / Save / Comment / Share — allowed on your own post.
- Not available: request-to-join, repost your own post, set interest on your own post (the API rejects these).
Third person (any other viewer):
- Like →
PUT /api/posts/:id/like(state inisLiked) - Save →
PUT /api/posts/:id/save(state inisSaved) - Interested / Not interested →
PUT /api/posts/:id/interest(state ininterestStatus) - Repost →
POST /api/posts/:id/repost/ undoDELETE …/repost(state inisReposted/viewerRepostId) - Share →
POST /api/posts/:id/share - Comment →
POST /api/posts/:id/comments - Request CTA — only if
cta.enabledandcta.actionable:- Submit →
POST /api/posts/:id/requests - Withdraw →
DELETE /api/posts/:id/requests/me(whenviewerRequestStatus = PENDING) - Open chat → navigate to
viewerRequestConversationId(whenACCEPTED)
- Submit →
7 — Saved list: unavailable shells
GET /api/posts/saved is special. If you bookmarked a post that later became
unviewable (deleted, hidden, or the author went private), the item comes back as
a shell instead of a full post, so you can show "no longer available" with a
tap-to-remove. Detect it by the isUnavailable flag:
{ "id": "<postId>", "isUnavailable": true, "unavailableReason": "DELETED" }
unavailableReason | Meaning |
|---|---|
DELETED | The post no longer exists. |
HIDDEN_BY_AUTHOR | The author hid it. |
RESTRICTED | The author's profile went private; you can't see it. |
Shells have only id, isUnavailable, unavailableReason — no author,
cta, etc. Always check isUnavailable before reading other fields on
saved-list items. Every other list endpoint returns full posts only.
8 — Gotchas
_count.requestsis0for non-authors — never use it to show request counts to a third person. It's author-only by design (no inbox-size leakage).viewerRequestConversationIdcan benulleven whenACCEPTED— if chat was blocked or DM linkage failed. If it'snull, fall back to opening the post instead of the chat.ctaobject is always present even on non-CTA kinds — checkcta.enabledfirst; whenfalse, its labels are empty strings.original/originalPostIdbothnullon aREPOST→ the original was deleted; render "Original removed".- Feed has no
commentsarray — use_count.commentsfor the number and fetchGET /api/posts/:id(or the comments endpoint) to show them. - Don't recompute role-based labels —
cta.buttonLabel,actionable, and_count.requestsare already resolved server-side for the requesting user.