Skip to main content
Version: Latest
UPDATED 2026-11-06 — New field likedBy on every post (list, feed, detail): up to 3 of the viewer's mutuals who liked it, plus mutualCount. Use it for "Liked by Charlie and 37 others". Same shape as repostedBy. null when no mutual liked it. Additive: nothing breaks if you ignore it.
- **2026-11-06** — New field `likedBy` on every post (list, feed, detail): up to 3 of the viewer's mutuals who liked it, plus `mutualCount`. Use it for "Liked by Charlie and 37 others". Same shape as `repostedBy`. `null` when no mutual liked it. Additive: nothing breaks if you ignore it.- **2026-10-28** — **Breaking:** `eventAt` and `deadlineAt` on find-teammates and invite-people are now dates (`"2026-06-12"`, no time). Show them as they are; do not convert them to the device timezone. Share-opportunity `eventAt` is still a full date-time. Details: [posts guide §1.2](./mobile-posts-guide.md).
- **2026-10-08** — **Breaking:** `interestStatus` is gone from every post. Don't show a tick for Interested / Not interested. The call is write-only now; see the [interest guide](./mobile-post-interested-notInterested.md).- **2026-10-05** — Every user object in a post (`author`, `comments[].author`, `repostedBy.users[]`, embedded original author) now carries **`isStudentVerified: boolean`** — show the verified badge when `true`. Additive: nothing breaks if you ignore it. Details: [Student verification guide](../student-verification/mobile-student-verification-guide.md#the-verified-badge).
| `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. || `likedBy` | object \| null | Social proof for likes: mutuals of this viewer who liked this post. `null` if none. Same shape as `repostedBy`: `{ users: [...], mutualCount }`, up to 3 named users, newest first. Render "Liked by Charlie, Dana and N others", where N = `_count.likes` minus the names shown. Keyed on this post's own id (like `isLiked`), not the root. May lag follow changes by up to 5 min. |

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 (see mobile-post-requests-guide.md and mobile-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: mobile on every request.

What's new​

  • 2026-11-06 — New field likedBy on every post (list, feed, detail): up to 3 of the viewer's mutuals who liked it, plus mutualCount. Use it for "Liked by Charlie and 37 others". Same shape as repostedBy. null when no mutual liked it. Additive: nothing breaks if you ignore it.
  • 2026-10-28 — Breaking: eventAt and deadlineAt on find-teammates and invite-people are now dates ("2026-06-12", no time). Show them as they are; do not convert them to the device timezone. Share-opportunity eventAt is still a full date-time. Details: posts guide §1.2.
  • 2026-10-27 — On find-teammates / invite-people / start-something, location becomes the picked city, state or country name only ("Pune"), and a new locationLevel (CITY | STATE | COUNTRY | null) tells you which. locationMode gains HYBRID. New additionalDetails (string \| null) on post detail only. Also corrected: this guide said only Start-Something sets locationMode; all 3 CTA kinds do. Details: posts guide §1.3.
  • 2026-10-08 — Corrected: the gotchas said original and originalPostId are both null when a repost's original is gone. Only original is reliably null, so key "Original removed" on that. originalPostId stays set when the original is hidden or its author is unavailable.
  • 2026-10-08 — Breaking: interestStatus is gone from every post. Don't show a tick for Interested / Not interested. The call is write-only now; see the interest guide.

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:

FieldList / feedSingle 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)​

FieldTypeMeaning
idstringThe post's unique id.
typestring enumPost kind: FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING, SHARE_OPPORTUNITY, ANYTHING, REPOST. Drives which optional fields are filled and whether a CTA exists.
titlestring | nullPost title. null on kinds that don't use one (e.g. plain ANYTHING, REPOST).
contentstringThe body text. Can be "" (empty) for media-only ANYTHING posts and always empty for simple reposts.
locationstring | nullPlace name to show. On the 3 CTA kinds: the picked city, state or country only ("Pune"). On Share-Opportunity / Anything: the author's free text. null if unset.
locationLevelstring | nullCITY | STATE | COUNTRY. What location names on the 3 CTA kinds. null on online posts, other kinds and old posts.
locationModestring | nullONLINE | IN_PERSON | HYBRID on the 3 CTA kinds. Old rows may still have CITY / SCHOOL. null on other kinds.
eventAtstring | nullWhen the thing happens. Find-Teammates / Invite: a date "YYYY-MM-DD" (show as is). Opportunity: ISO date-time. null if none.
deadlineAtstring | nullRequest cut-off, a date "YYYY-MM-DD"; passes at end of that day IST. Required on Find-Teammates / Invite-People (pre-existing rows may still be null).
linkstring | nullExternal URL (Share-Opportunity / Start-Something). null if none.
additionalDetailsstring | nullOptional "Additional details" text on the 3 CTA kinds. Post detail only — not on feed or list cards.
detailsobject | nullFree-form escape-hatch JSON for future per-kind fields. Usually null.
isHiddenbooleanAuthor hid the post. Hidden posts don't reach third-person viewers at all (they 404), so you'll normally see this false.
isAiGeneratedbooleanAuthor declared the post AI-generated. Render an AI tag on the card when true; nothing when false. Self-disclosure only — the server never infers it, and it has no effect on ranking, reach or moderation. The author can flip it by editing the post, so treat it as mutable.
createdAtISO date-timeWhen posted.
updatedAtISO date-timeLast edit time.
authorIdstringThe 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.

FieldTypeMeaning
author.idstringAuthor user id (same as authorId).
author.usernamestringHandle.
author.isStudentVerifiedbooleantrue → show the verified badge. Same field on every comment/liker/reposter user.
author.gradestring | nullAuthor's current grade (e.g. "5th"). null if unset. Absent for unverified viewers. Same on every user in a post.
author.schoolstring | nullAuthor's current school name. null if unset. Absent for unverified viewers.
author.profileobject | nullProfile card. null if the user has no profile row.
author.profile.displayNamestringShown name. Prefer this over username for display.
author.profile.avatarUrlstring | nullAvatar image URL. null → render initials/placeholder.

3.3 — Topics & media (same for everyone)​

topics — array of tag objects attached to the post:

FieldTypeMeaning
idstringTopic id.
slugstringURL-safe key.
namestringDisplay label.
iconstring | nullIcon name. null if none.

media — array of image/video rows (empty array if none):

FieldTypeMeaning
idstringMedia id.
typestringimage | video | gif.
urlstringThe full-resolution original. Can be several MB — do not load this in the feed (see 3.3.1).
widthnumber | nullPixel width of the original. Often null — prefer a variant's width for layout (see 3.3.1).
heightnumber | nullPixel height of the original. Same caveat as width.
altTextstring | nullAccessibility text. null if none.
processingStatusstringpending | processing | complete | failed. Governs whether variants is populated (see 3.3.1).
variantsarrayResized 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:

FieldTypeMeaning
variantTypestringthumbnail | small | medium | large.
urlstringRender this. WebP.
widthnumberActual pixel width of the variant.
heightnumberActual pixel height of the variant.
sizeBytesnumberByte size — always small (KB range).
variantTypeSizeUse for
thumbnail240×240 (square crop)Grid tiles, avatars-of-media, dense multi-image collages.
small≤750px wideFeed card default.
medium≤1080px wideSingle-image feed card on large phones / tablets.
large≤1440px widePost-detail screen.
(original)media.urlFull-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:

FieldTypeMeaning
likesnumberTotal likes.
commentsnumberTotal comments (top-level + replies).
repostsnumberTotal reposts of this post.
sharesnumberTotal times shared.
requestsnumberRole-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).

FieldTypeMeaning
isLikedbooleanDid this viewer like it. Drives the filled/empty heart.
isSavedbooleanDid this viewer bookmark it. Private — never shown to others. Drives the filled/empty bookmark.
viewerRequestStatusstring | nullThis 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).
viewerRequestConversationIdstring | nullThe 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.

FieldTypeMeaning
requestPromptstring | nullThe question shown to a requester, e.g. "Why are you a good fit?".
requestPromptDescriptionstring | nullHelper line under the prompt.
requestOptionsstring[]The multiple-choice chips a requester can pick from. Empty on non-CTA kinds.
requestPromptSourcestring | nullWhere the prompt came from: DEFAULT (catalog default), AUTHOR (author customized), AI, or null. Informational only.
requestStatestringAuthor'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.

FieldTypeMeaning
originalPostIdstring | nullOn 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.
originalobject | nullEmbedded 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, isAiGenerated, author, media } — the embedded card carries its own isAiGenerated, so a repost of an AI post still shows the tag on the quoted card.
isRepostedbooleanDid this viewer already repost this post (one-tap simple repost). Drives the filled/active repost icon.
viewerRepostIdstring | nullThe 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.
repostedByobject | nullSocial 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.
likedByobject | nullSocial proof for likes: mutuals of this viewer who liked this post. null if none. Same shape as repostedBy: { users: [...], mutualCount }, up to 3 named users, newest first. Render "Liked by Charlie, Dana and N others", where N = _count.likes minus the names shown. Keyed on this post's own id (like isLiked), not the root. 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​

FieldTypeMeaning
enabledbooleanDoes this post kind have a request CTA at all. false for SHARE_OPPORTUNITY, ANYTHING, REPOST. When false, show no request button.
verbstringThe action verb: join / attend / collaborate. Used in copy.
buttonLabelstringThe label to show on the button, already resolved for this viewer. This is the field you render.
actionablebooleanWhether the button should be tappable. false → render it disabled/informational (e.g. "Requested", "Closed", "Approved").
pendingLabelstringRaw label for the pending state (the resolver may have already put it in buttonLabel).
acceptedLabelstringRaw label for accepted state.
rejectedLabelstringRaw label for rejected state.
authorLabelstringRaw label the author sees ("View Responses").
pausedLabelstringRaw label for a fresh viewer when intake is paused.
completedLabelstringRaw label for a fresh viewer when intake is closed.
expiredLabelstringRaw label for a fresh viewer once the post's deadlineAt has passed ("Expired").

You mostly only need enabled, buttonLabel, and actionable. 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 situationbuttonLabel becomesactionableWhat to render
CTA disabled (enabled: false)""falseNo request button at all.
AuthorauthorLabel → "View Responses"trueTap → open the requests inbox for this post.
Third person, viewerRequestStatus = PENDINGpendingLabel → "Requested"trueAlready asked; can re-submit/withdraw.
Third person, viewerRequestStatus = ACCEPTEDacceptedLabel → "Open chat"falseTap → open DM at viewerRequestConversationId.
Third person, viewerRequestStatus = REJECTEDrejectedLabel → "Request Declined"falseDisabled; cannot re-ask.
Third person, fresh, requestState = COMPLETEDcompletedLabel → "Closed"falseDisabled.
Third person, fresh, isExpired = trueexpiredLabel → "Expired"falseDisabled. The author never sees this — theirs stays "View Responses".
Third person, fresh, requestState = PAUSEDpausedLabel → "Requests Paused"falseDisabled.
Third person, fresh, requestState = OPENbuttonLabel → "I am interested/Attend/Collaborate"trueTap → open the request form.

Note the exception: an ACCEPTED viewer's buttonLabel is "Open chat" but actionable is false — "not actionable" means "cannot submit a request", not "cannot tap". When the label is "Open chat", navigate to viewerRequestConversationId. 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?verbTypical filled fields
FIND_TEAMMATES✅ yesjointitle, content, topics, eventAt, deadlineAt, locationMode, (opt) media/location/link/additionalDetails
INVITE_PEOPLE✅ yesattendtitle, content, topics, eventAt, deadlineAt, locationMode, (opt) media/location/link/additionalDetails
START_SOMETHING✅ yescollaboratetitle, content, topics, locationMode, (opt) media/location/link/additionalDetails
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 real cta, requestPrompt*, requestOptions, and a meaningful requestState. Everything else has cta.enabled: false and empty request fields.
  • REPOST: render the reposter (author) as "X reposted", then render the original card underneath. If original is null and type is REPOST, 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 current requestState)
  • 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 in isLiked)
  • Save → PUT /api/posts/:id/save (state in isSaved)
  • Interested / Not interested → PUT /api/posts/:id/interest (write-only, no state on the post; see interest guide)
  • Repost → POST /api/posts/:id/repost / undo DELETE …/repost (state in isReposted / viewerRepostId)
  • Share → POST /api/posts/:id/share
  • Comment → POST /api/posts/:id/comments
  • Request CTA — only if cta.enabled and cta.actionable:
    • Submit → POST /api/posts/:id/requests
    • Withdraw → DELETE /api/posts/:id/requests/me (when viewerRequestStatus = PENDING)
    • Open chat → navigate to viewerRequestConversationId (when ACCEPTED)

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" }
unavailableReasonMeaning
DELETEDThe post no longer exists.
HIDDEN_BY_AUTHORThe author hid it.
RESTRICTEDThe 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.requests is 0 for non-authors — never use it to show request counts to a third person. It's author-only by design (no inbox-size leakage).
  • viewerRequestConversationId can be null even when ACCEPTED — if chat was blocked or DM linkage failed. If it's null, fall back to opening the post instead of the chat.
  • cta object is always present even on non-CTA kinds — check cta.enabled first; when false, its labels are empty strings.
  • original is null on a REPOST → render "Original removed". Key on original, not originalPostId: both are null only when the original was hard-deleted. The id stays set when the original is hidden or its author is unavailable (moderation-hidden or in the 30-day deletion window).
  • Feed has no comments array — use _count.comments for the number and fetch GET /api/posts/:id (or the comments endpoint) to show them.
  • Don't recompute role-based labels — cta.buttonLabel, actionable, and _count.requests are already resolved server-side for the requesting user.
⤓ Download .md