Skip to main content
Version: Latest
UPDATED 2026-11-06 — Breaking: GET /api/posts/:id/likes is now paged: 20 likers by default, newest first. Send limit (max 100) and pass nextCursor back as cursor for more. The followers-first sort is gone; use isFollowing / isFollower on each row. total is now the post's like count. Also new on every post: likedBy, for the "Liked by A and N others" line. See §8. Also: limit on lists that had no cap is now capped at 100; a bad limit returns the default page size (§11).
- **2026-11-06** — **Breaking:** `GET /api/posts/:id/likes` is now paged: 20 likers by default, newest first. Send `limit` (max 100) and pass `nextCursor` back as `cursor` for more. The followers-first sort is gone; use `isFollowing` / `isFollower` on each row. `total` is now the post's like count. Also new on every post: `likedBy`, for the "Liked by A and N others" line. See §8.- **2026-11-06** — **Breaking:** `GET /api/posts/:id/likes` is now paged: 20 likers by default, newest first. Send `limit` (max 100) and pass `nextCursor` back as `cursor` for more. The followers-first sort is gone; use `isFollowing` / `isFollower` on each row. `total` is now the post's like count. Also new on every post: `likedBy`, for the "Liked by A and N others" line. See §8. Also: `limit` on lists that had no cap is now capped at 100; a bad `limit` returns the default page size (§11).- **2026-11-06** — `GET /api/posts/saved` now works for unverified students. It no longer returns 403 `STUDENT_VERIFICATION_REQUIRED`. Users can open their own saved list before they verify. Nothing to change in the client.
`limit` on every list endpoint in the API is held to a range. A value above theendpoint's max returns the max (100 unless the endpoint documents a lower one,for example 50 on the feed and post lists). A missing, zero, negative ornon-number `limit` returns the default page size. It never errors. A fewendpoints that validate `limit` strictly (post requests, mention search) stillreturn 400 on a bad value. Comment lists use the comment ID as the cursor (simpler than time-based for

Mobile Posts — Integration Guide

For the mobile development. Everything needed to create, edit, read, list, like, and comment on posts. No backend changes needed on your side.

Access control: the private-profile gate (per docs/private-profile-implementation.md) gates every post by its author's profile-privacy state. Restricted profiles return 403 on follower/following lists, and posts by private authors return 404 to non-followers. Saved-list entries by an author who later flips private come back as shells with isUnavailable: true rather than dropping. Per-post visibility (Everyone / Friends) is deferred to a future sprint — see docs/post-visibility-implementation.md.

  • API base URL (local): http://localhost:3001
  • All requests are JSON. Send Content-Type: application/json.
  • Send X-Client-Type: mobile on every request.
  • Auth: Authorization: Bearer <accessToken> on every endpoint below (except the S3 PUT for media upload).

What's new​

  • 2026-11-06 — Breaking: GET /api/posts/:id/likes is now paged: 20 likers by default, newest first. Send limit (max 100) and pass nextCursor back as cursor for more. The followers-first sort is gone; use isFollowing / isFollower on each row. total is now the post's like count. Also new on every post: likedBy, for the "Liked by A and N others" line. See §8. Also: limit on lists that had no cap is now capped at 100; a bad limit returns the default page size (§11).
  • 2026-11-06 — GET /api/posts/saved now works for unverified students. It no longer returns 403 STUDENT_VERIFICATION_REQUIRED. Users can open their own saved list before they verify. Nothing to change in the client.
  • 2026-11-03 — The author can now edit externalFormUrl with PATCH /api/posts/:id. Send a new https link to replace it. This works only on a post created with an external form. On any other post it returns 400. You still cannot switch a post between in-app and external, and null does not clear the link. This guide said the field was never editable. That was too strict.
  • 2026-11-02 — New optional field externalFormUrl on the 3 CTA kinds. Send an https link (for example a Google Form) when the author wants to collect responses outside the app. Every post object then has cta.externalUrl (viewers) and cta.handledExternally (everyone): when cta.externalUrl is set, open that link on the CTA tap and do not show the request form. The server rejects in-app requests on such a post with 409 EXTERNAL_FORM_POST. It can only be set at create. Editing it returns 400. Posts without it work as before.
  • 2026-10-30 — GET /api/posts/saved takes a new optional type query. Send one post type (FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING, SHARE_OPPORTUNITY or ANYTHING) to see only saved posts of that type. Leave it out for all saves. Nothing breaks.

Short version — just the API calls​

0. GET /api/posts/types → post-type catalog (summary: 5 kinds)
GET /api/posts/types/:slug → full detail for one kind (fields + CTA + defaults)
GET /api/users/topics → interest/topic catalog
1. POST /api/media/presign → { uploadUrl, publicUrl } (per file)
PUT <uploadUrl> (file bytes, direct to S3)
2. POST /api/posts → create post
3. GET /api/posts/:id → single post
4. GET /api/posts/me → current user's posts
5. GET /api/posts/user/:username → another user's posts
6. GET /api/posts?type=&topicSlug=... → filtered list
7. GET /api/feed → personalized feed
8. PATCH /api/posts/:id → edit a post
9. DELETE /api/posts/:id → delete a post
10. PUT /api/posts/:id/like → set like state (idempotent, body: {liked})
11. PUT /api/posts/:id/save → set save state (idempotent, body: {saved})
12. GET /api/posts/saved → viewer's saved posts (private bookmarks)
13. POST /api/posts/:id/repost → one-tap repost (empty body)
14. DELETE /api/posts/:id/repost → undo repost
15. GET /api/posts/:id/reposts → list of repost rows
16. POST /api/posts/:id/share → record share + return tracked URL
17. GET /api/posts/:id/share/preview → share-sheet prefill (title/desc/image)
18. GET /api/posts/:id/comments → list comments + replies
19. POST /api/posts/:id/comments → add comment / reply
20. PATCH /api/posts/:postId/comments/:commentId → edit own comment (15-min window, max 5 edits)

All catalogs (/posts/types, /posts/types/:slug, /users/topics) send Cache-Control: public, max-age=3600. Cache them locally for the session.


1 — Post types (drive your form)​

Two-call split:

  • GET /api/posts/types → summary list. Drives the type picker.
  • GET /api/posts/types/:slug → full detail for one kind. Drives the form on selection (fields, CTA, request-prompt defaults).

1.1 — Summary list​

{
"success": true,
"data": [
{
"kind": "FIND_TEAMMATES",
"slug": "find-teammates",
"name": "Find Teammates",
"description": "Build a team or find partners for something you want to do",
"icon": "users",
"order": 1,
},
{
"kind": "INVITE_PEOPLE",
"slug": "invite-people",
"name": "Invite People",
"description": "Get others to join something you're hosting",
"icon": "mail",
"order": 2,
},
{
"kind": "START_SOMETHING",
"slug": "start-something",
"order": 3 /* ... */,
},
{
"kind": "SHARE_OPPORTUNITY",
"slug": "share-opportunity",
"order": 4 /* ... */,
},
{ "kind": "ANYTHING", "slug": "anything", "order": 5 /* ... */ },
],
}

Use the catalog as the source of truth. Don't hardcode the kind list — it WILL change. Render the picker from data[] ordered by order.

1.2 — Per-kind detail (GET /api/posts/types/:slug)​

Call this on kind selection (or pre-warm all five on cold-start — they're 1h cached). Returns fields, CTA, request-prompt defaults:

{
"success": true,
"data": {
"kind": "FIND_TEAMMATES",
"slug": "find-teammates",
"name": "Find Teammates",
"description": "Build a team or find partners for something you want to do",
"icon": "users",
"order": 1,
"fields": {
"required": [
"title",
"content",
"topicSlugs",
"eventAt",
"locationMode",
"deadlineAt",
],
"optional": [
"media",
"locationPlace",
"link",
"additionalDetails",
"isAiGenerated",
],
},
"cta": {
"enabled": true,
"verb": "join",
"buttonLabel": "I am interested",
"pendingLabel": "Request Sent",
"acceptedLabel": "Joined",
"rejectedLabel": "Request Declined",
"authorLabel": "View Responses",
},
"defaultRequestPrompt": "Why are you a good fit?",
"defaultRequestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
"defaultRequestOptions": ["I have experience", "I'm interested", "..."],
},
}

Render only fields in required ∪ optional. Mark required ones. 404 on unknown slug.

Per-kind field sets:

slugrequiredoptional
find-teammatestitle, content, topicSlugs, eventAt, locationMode, deadlineAtmedia, locationPlace, link, additionalDetails, externalFormUrl, isAiGenerated
invite-peopletitle, content, topicSlugs, eventAt, locationMode, deadlineAtmedia, locationPlace, link, additionalDetails, externalFormUrl, isAiGenerated
start-somethingtitle, content, topicSlugs, locationModemedia, locationPlace, link, additionalDetails, externalFormUrl, isAiGenerated
share-opportunitytitle, content, topicSlugs, location, eventAtmedia, link, isAiGenerated
anything(none)content, media, location, topicSlugs, isAiGenerated

isAiGenerated is optional on every kind — it's the AI-content toggle, and it's in the catalog so a form built from required ∪ optional renders it without special-casing. Nothing about it is per-kind.

The 3 CTA kinds carry an Online / In-person / Hybrid toggle (locationMode). It's required, but locationPlace / link show as optional above because which one is required depends on the toggle — the server enforces the real rule (see §1.3). This mirrors anything, whose required list is empty while the server still requires content-or-media (§4.5).

Field meanings:

field nametypesent as
titlestring ≤120body.title
contentstring ≤2000body.content (description body)
topicSlugsstring[] (1–2)body.topicSlugs — category slugs from /api/users/topics (§2). Required on the 4 CTA/event kinds; optional on anything (§4.5)
mediaobject[] (≤10)body.media — see §3
locationstring ≤200body.location — free text. share-opportunity and anything only. On the 3 CTA kinds the server fills it from locationPlace.name (§1.3)
locationModeenumbody.locationMode — ONLINE | IN_PERSON | HYBRID on the 3 CTA kinds (§1.3)
locationPlaceobjectbody.locationPlace — the picked city / state / country (§1.3). Stored, never returned
additionalDetailsstring ≤1000body.additionalDetails — optional free text, 3 CTA kinds only. Returned only by GET /api/posts/:id (§1.4)
externalFormUrlhttps URL ≤2048body.externalFormUrl — optional, 3 CTA kinds only. Mode fixed at create; the link is editable only if already set. Returned on every surface (§1.5)
eventAtdate or datetimebody.eventAt — "YYYY-MM-DD" on find-teammates / invite-people; ISO 8601 date-time on share-opportunity (see below)
deadlineAtdatebody.deadlineAt — "YYYY-MM-DD", required on find-teammates / invite-people
linkURLbody.link — meeting URL (online) or RSVP/details URL
isAiGeneratedbooleanbody.isAiGenerated — the AI-content toggle. Optional on every kind, defaults to false. See §4

eventAt / deadlineAt on find-teammates / invite-people are dates with no time. Show a date picker only. Send the picked day as "YYYY-MM-DD":

const d = new Date(2026, 7, 30); // Aug 30, picked on the device
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
eventAt: ymd; // "2026-08-30"
  • Build the string from the local date parts, as above. Do not use toISOString().slice(0, 10): it converts to UTC first, so in India a date picked before 05:30 becomes the day before.
  • Every surface returns the same string ("2026-08-30"). Show it as it is. Do not parse it into a Date and format it in the device timezone: in a timezone behind UTC that shows the day before.
  • A full date-time ("2026-08-30T18:30:00.000Z") is also accepted. The server drops the time and keeps the IST date (that example becomes "2026-08-31"). Prefer sending "YYYY-MM-DD". Anything else gets a 400 (Must be a date (YYYY-MM-DD) on create, eventAt must be a date (YYYY-MM-DD) on INVITE_PEOPLE posts on edit).
  • A day ends at end of day IST (23:59:59, UTC+5:30). The deadline passes then (isExpired turns true, new requests get 409). After the event day ends, the post locks for edits (409). upcomingOnly=true keeps the post through its whole event day.
  • Posts created before this change return their IST date too.

share-opportunity is different: its eventAt is still one instant — a full ISO date-time. Merge the date and time pickers in device-local time, then send at.toISOString(). There is no time field; an unknown key is silently dropped, so a separate "time" is lost with no error.

1.3 — Location: Online / In-person / Hybrid (the 3 CTA kinds only)​

FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING each require a locationMode with one of three values:

locationModeshowlocationPlacelink
ONLINElink fieldnot allowedoptional
IN_PERSONlocation pickerrequiredoptional
HYBRIDlocation picker + linkrequiredoptional

link is optional in every mode. An online event may share its link later, for example in chat.

No exact location. We do not take a venue, street address or map pin any more. The location picker searches cities, states and countries only. The user types text, the client shows the matching cities, states and countries, and the user picks one. The search runs on the client. The API has no search endpoint for this.

Request. Send the picked result as locationPlace:

"locationMode": "HYBRID",
"locationPlace": {
"level": "CITY", // required — CITY | STATE | COUNTRY
"name": "Pune", // required — the name of the picked result
"state": "Maharashtra", // optional
"country": "India", // optional
"countryCode": "IN", // optional, ISO 3166-1 alpha-2
"placeId": "ChIJ..." // optional — provider id, if you have one
// any other keys are stored as-is and never returned
}
  • name is the picked result itself: the city name for CITY, the state name for STATE, the country name for COUNTRY.
  • Do not send location on these 3 kinds. The server copies locationPlace.name into location.
  • Send the extra fields (state, country, countryCode, placeId) when the search result has them. The server keeps them for later features, such as "posts near me". They are never sent back to any client.

Response. Every surface (feed, lists, detail) returns the same two fields:

"locationMode": "HYBRID",
"location": "Pune", // only the picked name — never "Pune, Maharashtra"
"locationLevel": "CITY" // CITY | STATE | COUNTRY | null
  • Show location as plain text. Use locationLevel if you want a different icon or label for a city, a state or a country.
  • locationPlace is not in any response.
  • ONLINE posts: location and locationLevel are null. Show link as the join URL.
  • HYBRID posts: show both the place and the link.

Validation errors (HTTP 400):

you senterror
ONLINE with a locationPlacelocationPlace is not allowed on online posts
IN_PERSON or HYBRID with no locationPlacelocationPlace is required for in-person and hybrid posts
location on a CTA kindlocation is set from locationPlace — do not send it
locationPlace without level or namevalidation error on locationPlace.level / locationPlace.name
the old Google Places blob (formattedAddress, lat, lng, no level)validation error on locationPlace.level
locationPlace larger than 2000 characters of JSONlocationPlace is too large

Old posts. Posts created before this change keep their data. They return their old city (or old typed text) in location, with locationLevel: null. Old locationMode values CITY / SCHOOL still render. The composer never sends them.

Maps link. There are no coordinates, so there is no map pin. If you want a "open in Maps" action, build a text search from the name: https://www.google.com/maps/search/?api=1&query=<encodeURIComponent(post.location)>.

1.4 — Additional details (the 3 CTA kinds only)​

An optional free-text section at the end of the form.

  • Request: "additionalDetails": "Bring your own laptop. Snacks provided."
  • Plain text, max 1000 characters. Leave it out when the user writes nothing.
  • Response: only GET /api/posts/:id (and the create / update response) returns additionalDetails (string | null). Feed cards and lists do not.
  • SHARE_OPPORTUNITY / ANYTHING: a value → 400 on edit. On create the key is ignored.
  • Edit: send a new string to change it, null to clear it, or leave it out.

1.5 — External response form (the 3 CTA kinds only)​

By default responses are collected in the app (a request, then a DM). The author can instead point the CTA at their own form, for example a Google Form.

  • Request: "externalFormUrl": "https://forms.gle/abc". Only on find-teammates, invite-people, start-something. https only, max 2048 characters. Leave it out for in-app responses.
  • Composer: show a "Collect responses" choice (in app / external form). Show the link field only for "external form".
  • Mode is fixed at create. A post made with a link can later get a new link: PATCH with a new https value. PATCH with a value on a post made without a link returns 400. null is ignored, so the link cannot be removed. Show the link field in the edit form only when externalFormUrl is set.
  • Every surface that returns a post (detail, feed, lists, repost original) carries externalFormUrl (string | null) and two cta fields:
FieldViewerAuthor
cta.handledExternallytrue when the post uses an external formsame
cta.externalUrlthe form link, or nullalways null
  • Viewer CTA tap: if cta.externalUrl is set, open it in the browser. Do not open the request form. No request and no chat are created. buttonLabel, actionable, and the paused / closed / expired labels work as before.
  • Author's responses screen: if cta.handledExternally is true, show "Responses are collected on your external form" with the link, instead of the responses list. See the requests guide §4.

2 — Topics dropdown (interest tags)​

GET /api/users/topics — the 9 interest categories. Cached 1h; fetch once per session and reuse. Same response shape as everywhere else.

Onboarding's interest step is a different list — ?representative=1, ~86 fine-grained topics. Don't share one cached list between the two screens. See mobile-topics-interests-guide.md.

{
"success": true,
"data": [
{
"id": "tpc_xxx",
"slug": "sports-fitness",
"name": "Sports & Fitness",
"category": "Sports & Fitness",
"icon": null,
},
{
"id": "tpc_yyy",
"slug": "arts-creativity",
"name": "Arts & Creativity",
"category": "Arts & Creativity",
"icon": null,
},
// ... 9 in total
],
}

Send the chosen slug values back as topicSlugs: ["sports-fitness","arts-creativity"] — the slug, never the display name. Unknown slugs are silently dropped server-side, so a create with only bad slugs succeeds with no topics; validate against this list client-side.


3 — Media flow​

Same presign flow as onboarding. Two steps per file:

3.1 — Presign​

POST /api/media/presign
Content-Type: application/json
Authorization: Bearer <accessToken>

{
"files": [
{ "filename": "photo1.jpg", "contentType": "image/jpeg", "size": 245678 }
]
}

Posts are image-only (image/jpeg, image/png, image/webp, max 10 MB; image/heic/image/heif iOS photos, max 15 MB). Video and gif are rejected. size (exact byte length) is required — the server caps it and pins it as the signed Content-Length.

Response:

{
"success": true,
"data": {
"files": [
{
"uploadUrl": "https://s3.../signed?...",
"publicUrl": "https://s3.../uploads/abc.jpg",
},
],
},
}

3.2 — PUT to S3​

PUT <uploadUrl>
Content-Type: image/jpeg ← MUST match what you sent to /presign
Content-Length: 245678 ← MUST match the presigned size (exact)

<binary bytes>

No auth headers — the signed URL is the credential. Storage rejects a body whose length isn't exactly the presigned size (403).

3.3 — Include media inline on create​

Pass each uploaded file in the media[] array of the create body. Server creates the Media rows polymorphically (ownerType=post) in the same request.

"media": [
{
"type": "image", // image ONLY (video/gif rejected on posts)
"url": "https://s3.../uploads/abc.jpg",
"thumbnailUrl": "https://...", // optional
"width": 1080, // optional
"height": 1920, // optional
"size": 245678, // optional, bytes
"altText": "Team photo" // optional
}
]

Max 10 media per post. Media is set at create time only — editing a post cannot add media. To remove existing items, PATCH with removeMediaIds (see §6).


4 — Creating a post​

Same endpoint for every kind. The type field discriminates. Server validates per-kind required set.

isAiGenerated (optional, every kind). The composer's "this post is AI content" toggle. Send true when the author ticks it; omit the key (or send false) otherwise — the column defaults to false. It's a pure self-disclosure: the server never sets or infers it, and it changes nothing about reach, ranking or moderation. It comes back on the post everywhere (§4.6) — render an AI tag when true. The catalog lists it under fields.optional for every kind (§1.2), so a form built from required ∪ optional picks the toggle up on its own.

4.1 — Find Teammates​

POST /api/posts
{
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th for our hackathon team",
"content": "Building a flashcard app. Need a designer or another dev.",
"topicSlugs": ["technology-innovation"],
"eventAt": "2026-06-12", // event date — no time (§1.2)
"deadlineAt": "2026-06-10", // deadline — ends 23:59:59 IST that day
"locationMode": "IN_PERSON", // required — ONLINE | IN_PERSON | HYBRID (§1.3)
"locationPlace": { // required when IN_PERSON / HYBRID; no `location`
"level": "CITY", "name": "San Francisco",
"state": "California", "country": "United States", "countryCode": "US"
},
"additionalDetails": "We meet in the library. Bring a laptop.", // optional (§1.4)
"media": [ { "type": "image", "url": "..." } ],
"isAiGenerated": false // optional, every kind — true renders an AI tag
}

Online variant: "locationMode": "ONLINE", optional "link": "https://meet.example/x", and no locationPlace. See §1.3 for the full rule.

4.2 — Invite People​

{
"type": "INVITE_PEOPLE",
"title": "Beach cleanup this Saturday",
"content": "Bring gloves. We'll provide bags.",
"topicSlugs": ["business-leadership"],
"eventAt": "2026-06-12", // date only (§1.2)
"deadlineAt": "2026-06-10", // deadline — date only
"locationMode": "HYBRID", // required — ONLINE | IN_PERSON | HYBRID (§1.3)
"locationPlace": {
"level": "STATE", // the user picked a state
"name": "California",
"country": "United States",
"countryCode": "US",
},
"link": "https://meet.example/beach", // optional in every mode
"isAiGenerated": false, // optional, every kind — true renders an AI tag
}

4.3 — Start Something New​

{
"type": "START_SOMETHING",
"title": "Starting a robotics club",
"content": "Anyone interested in meeting weekly to build robots?",
"topicSlugs": ["technology-innovation", "competitive-activities"],
"locationMode": "ONLINE", // required — ONLINE | IN_PERSON | HYBRID (§1.3)
"link": "https://discord.gg/robotics", // optional; no locationPlace when ONLINE
"isAiGenerated": false, // optional, every kind — true renders an AI tag
}

In-person variant: "locationMode": "IN_PERSON" with locationPlace.

4.4 — Share an Opportunity​

{
"type": "SHARE_OPPORTUNITY",
"title": "STEM scholarship — applications open",
"content": "Open to grades 9–12. $5000 award.",
"topicSlugs": ["academics-learning"],
"location": "Online",
"eventAt": "2026-07-01T23:59:59Z", // deadline / event time
"link": "https://example.org/apply", // optional external URL
"isAiGenerated": false, // optional, every kind — true renders an AI tag
}

4.5 — Post Anything​

{
"type": "ANYTHING",
"content": "Just had the best burrito of my life.",
"media": [{ "type": "image", "url": "..." }],
"isAiGenerated": false, // optional, every kind — true renders an AI tag
}

type is the only structurally required field. topicSlugs is optional on this kind only — omit the key, or send []; both mean the same thing and the server derives the feed signal itself from the text. Send 1–2 slugs when the author picked topics and those win over the derived ones. Every other kind still rejects a create without topicSlugs (400).

The one rule the server does enforce: content or media — a post with neither is a 400 on content ("Add some text or at least one photo/video").

4.6 — Response shape (all kinds)​

This is the detail shape (create / update / GET /api/posts/:id). List and feed responses are identical except that they have no additionalDetails (§1.4). locationPlace is in no response (§1.3).

{
"success": true,
"data": {
"id": "pst_xxx",
"type": "FIND_TEAMMATES",
"title": "...",
"content": "...",
"location": "Pune", // the picked name only (§1.3)
"locationMode": "IN_PERSON",
"locationLevel": "CITY", // CITY | STATE | COUNTRY | null
"additionalDetails": null, // detail only — never on list/feed (§1.4)
"eventAt": "2026-06-12", // date on FIND_TEAMMATES / INVITE_PEOPLE; ISO date-time on SHARE_OPPORTUNITY
"deadlineAt": "2026-06-10",
"link": null,
"details": null,
"authorId": "usr_yyy",
"isHidden": false,
"isAiGenerated": false, // author's AI-content disclosure — render an AI tag when true
"createdAt": "2026-05-19T12:00:00Z",
"updatedAt": "2026-05-19T12:00:00Z",
"author": {
"id": "usr_yyy",
"username": "alice",
"profile": { "displayName": "Alice", "avatarUrl": "..." },
},
"topics": [
{ "id": "tpc_xxx", "slug": "coding", "name": "Coding", "icon": "code" },
],
"media": [
{
"id": "med_zzz",
"type": "image",
"url": "...",
"thumbnailUrl": "...",
"width": 1080,
"height": 1920,
"altText": null,
},
],
"_count": { "likes": 0, "comments": 0 },
"isLiked": false,
},
}

The response shape is identical across all kinds. Kind-specific columns are null when not applicable. This means your detail screen / list cell can use one render path with conditional sub-views by type.


5 — Reading posts​

5.1 — Single post​

GET /api/posts/:id

Returns the same shape as create. Includes top-level comments[] (just the first batch — paginate via §11 for more).

5.2 — Current user's posts​

GET /api/posts/me?limit=20&cursor=<base64>

5.3 — Another user's posts​

GET /api/posts/user/:username?limit=20&cursor=<base64>

404 if username doesn't exist.

5.4 — List with filters​

GET /api/posts?type=FIND_TEAMMATES&topicSlug=coding&upcomingOnly=true&limit=20

Query params (all optional, all combine with AND):

parammeaning
typeFIND_TEAMMATES/INVITE_PEOPLE/START_SOMETHING/SHARE_OPPORTUNITY/ANYTHING
topicSlugonly posts tagged with this topic
authorUsernameonly posts by this user. Student-verified viewers only (403 STUDENT_VERIFICATION_REQUIRED otherwise)
upcomingOnly"true" → only events not yet over: date-only kinds through the end of their day (IST), others eventAt >= now()
cursorfrom previous response's nextCursor
limitdefault 20, max 50

5.5 — Personalized feed​

GET /api/feed?limit=20&cursor=<id>

Unchanged from before. Blends follows + interests + trending. Items have the same shape as list endpoints plus score, pool, mode.

5.6 — List response shape​

{
"success": true,
"data": {
"items": [
/* array of full post objects, same shape as §4.6 */
],
"nextCursor": "eyJj...", // or null if no more pages
},
}

Pass nextCursor back as ?cursor= to fetch the next page. Stop when null.


6 — Updating a post​

PATCH /api/posts/:id

All fields optional — omit a field to leave it unchanged. type is immutable — cannot change after create.

6.1 — Per-type field validation​

Edits are validated against the post's type using the same catalog that drives your create form (GET /api/posts/types/:slug, fields.required / fields.optional):

the field is…value sentnull sent
required for the typeapplied400 — cannot be cleared
optional for the typeappliedapplied — clears the field
not in the type's form400 — not supportedsilently ignored

The null-is-ignored rule means you can PATCH your whole flat form state — sending null for every field the type's form doesn't render — without triggering errors. A 400 only fires when you send an actual value for a field the type doesn't have (e.g. deadlineAt on a START_SOMETHING post), which indicates a client bug.

6.2 — Per-type edit matrix​

typeeditable, can't clear (null → 400)editable + clearable (null clears)rejected if value sent (null ignored)
FIND_TEAMMATEStitle, content, topicSlugs, eventAt, locationMode, deadlineAtlocationPlace, link, additionalDetailslocation
INVITE_PEOPLEtitle, content, topicSlugs, eventAt, locationMode, deadlineAtlocationPlace, link, additionalDetailslocation
START_SOMETHINGtitle, content, topicSlugs, locationModelocationPlace, link, additionalDetailslocation, eventAt, deadlineAt
SHARE_OPPORTUNITYtitle, content, topicSlugs, location, eventAtlinklocationMode, locationPlace, deadlineAt, additionalDetails
ANYTHINGtopicSlugs, contentlocationtitle, locationMode, locationPlace, eventAt, deadlineAt, link, additionalDetails
REPOST——all typed fields (reposts aren't editable)

Rules that apply to every type:

  • topicSlugs REPLACES the topic set (never merged, cannot be null).
  • isAiGenerated is editable on every type, both directions. It's optional on every kind (§1.2), so no type rejects it. Send true to add the disclosure, false to drop it, omit it to leave it alone — and null is ignored, not a clear, so a flat form dump is safe (false is how you clear it; there is no third state). The event-date freeze below still applies: once eventAt has passed, no edit lands, including this one.
  • Media cannot be added on edit (media is create-only). To remove existing media, pass removeMediaIds — an array of media ids (from the post's media[]). Ids that don't belong to this post are ignored. Removed rows are deleted along with their storage blobs.
  • requestPrompt / requestPromptDescription / requestOptions — CTA-enabled kinds only (FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING); frozen with 409 once the first request exists (§15.12). On non-CTA kinds a value → 400, null → ignored.
  • The location rules apply on edit too (§1.3). The server checks the merged result. Example: flip locationMode to ONLINE while the post still has a place → 400 (locationPlace is not allowed on online posts). Going online: send locationPlace: null. Going in-person or hybrid: send a new locationPlace. A new locationPlace replaces the old one, and the server updates location and locationLevel from it. Legacy CITY / SCHOOL rows are not checked.
  • Send locationPlace only when the user picks a new place. The response never has locationPlace, so the edit form can't send the old one back. Show the current pick from location + locationLevel. Leave locationPlace out when it did not change. null is not ignored here — it clears the place, and on an IN_PERSON / HYBRID post that is a 400.
  • Time-bound posts freeze after the event. Once eventAt has passed (for find-teammates / invite-people: once that day has ended, IST), any edit returns 409 ("Post can no longer be edited after its event date"). Posts without eventAt never lock. Hide/disable the edit action when the event date is in the past.

6.3 — Examples​

Edit a FIND_TEAMMATES post — move the event, move the deadline, remove a photo:

PATCH /api/posts/pst_xxx
{
"title": "Updated title",
"content": "Updated description",
"topicSlugs": ["coding", "design", "art"],
"eventAt": "2026-06-15", // required for this type — a date; null or a time would 400
"deadlineAt": "2026-06-14", // required for this type — a date; null or a time would 400
"removeMediaIds": ["med_abc123"]
}

Edit a START_SOMETHING post — flat form dump, nulls for fields the form doesn't render are fine:

PATCH /api/posts/pst_yyy
{
"title": "Robotics club v2",
"content": "Now meeting twice a week.",
"topicSlugs": ["robotics"],
"locationMode": "ONLINE",
"locationPlace": null, // optional for this type — clears the place
"location": null, // not editable on this type — null is ignored
"eventAt": null, // not in this type — ignored, no error
"deadlineAt": null, // not in this type — ignored, no error
"link": null // optional for this type — clears it
}

Sending "deadlineAt": "2026-08-01" (a real value) on that same START_SOMETHING post → 400 "deadlineAt is not supported on START_SOMETHING posts".

6.4 — Errors​

codemeaning
400Value for a field the type doesn't have, or null for a required-for-type field
403Not the post author
404Post not found (or author private + viewer not approved)
409Event date has passed, or request prompt frozen after first request (§15.12)

7 — Deleting a post​

DELETE /api/posts/:id

Hard-delete. Cascades comments, likes, media (rows + storage blobs), mentions. 403 if not author. 404 if not found.


8 — Like / unlike​

PUT /api/posts/:id/like
Content-Type: application/json

{ "liked": true } // or false to unlike

Idempotent. {liked: true} is a no-op if already liked; {liked: false} is a no-op if not liked. The client sends the desired state directly, so the server never has to reconstruct intent from arrival order.

Response:

{
"success": true,
"data": {
"liked": true, // echoes the requested state
"updatedAt": "2026-05-22T07:35:40.451Z",
},
}

isLiked on the post object reflects the current user's state. _count.likes on list / detail responses is the authoritative count — trust it on refetch / hydration / pagination.

GET /api/posts/:id/likes?limit=20&cursor=… returns the liker list in pages, newest like first. limit default 20, max 100. Pass nextCursor back as cursor for the next page; null means the last page. total is the post's like count. Each row has isFollowing / isFollower.

{
"success": true,
"data": {
"likes": [{ "id": "...", "username": "...", "profile": { ... }, "isFollowing": true, "isFollower": false }],
"total": 38,
"nextCursor": "eyJ..." // null on the last page
}
}

Every post object carries likedBy: the viewer's mutuals who liked it, for the "Liked by A, B and N others" line. Same shape and rules as repostedBy — see data structure guide §3.

8.1 — Optimistic UI (mandatory pattern)​

Do not show a spinner on the like button. Apply the change locally on tap and reconcile with the server in the background.

  1. On tap — flip isLiked and bump _count.likes by +1 (like) or -1 (unlike) immediately in the client store. Record the new value as the desired state for this post.
  2. Fire PUT /api/posts/:id/like with { liked: <desired> } in the background. No await on the UI thread.
  3. On HTTP error — roll back the local state and surface a toast ("Couldn't update — tap to retry"). Do not block other interactions.
  4. On feed refetch / detail screen open — trust the server's _count.likes and isLiked as the source of truth; overwrite local state.

8.2 — Race conditions (rapid tap)​

PUT is idempotent and carries the desired state in the body, so the server no longer guesses intent from arrival order. But rapid taps can still produce out-of-order responses on the wire, so:

  • Serialize per post — keep at most one in-flight like mutation per postId. Cancel the previous one before issuing a new one. With TanStack Query: queryClient.cancelQueries({ queryKey: ["post-like", postId] }) inside onMutate.
  • Track desired state — store the user's latest intended liked value client-side. After each response, only apply it if its updatedAt is strictly newer than the last applied response for that post. Discard older updatedAt responses — they reflect an obsolete tap.
  • Idempotent retry on failure — safe to re-send the same { liked: <desired> } body. PUT is idempotent, so retrying never double-counts.

The endpoint is also rate-limited (per-user, 30 calls per 10s). Don't re-fire on every render — debounce taps that happen faster than a human can tap.


9 — Comments​

9.1 — List​

GET /api/posts/:id/comments?limit=20&cursor=<commentId>

Returns top-level comments, ordered newest first. Single-level nesting (Instagram-style): each top-level comment carries a flat replies[] array — replies are never themselves nested. Auth required (Bearer token) — the response's per-comment isLiked is computed for the calling user.

{
"success": true,
"data": {
"items": [
{
"id": "cmt_xxx",
"content": "...",
"authorId": "...",
"postId": "...",
"parentId": null,
"createdAt": "...",
"author": {
/* id, username, profile */
},
"likeCount": 3,
"isLiked": false, // viewer's like state on this comment
"replies": [
{
/* same shape (incl. likeCount + isLiked),
parentId = the top-level comment id.
replies[] is NOT present on these (flat — one level only). */
},
],
},
],
"nextCursor": "cmt_yyy",
"total": 42,
},
}

9.2 — Create comment / reply​

POST /api/posts/:id/comments
{
"content": "@bob Sounds great!", // client owns the text, incl. any @handle
"parentId": "cmt_xxx" // omit for top-level
}

Single-level nesting + client-owned composer. parentId may point at a top-level comment or at a reply — the server flattens threading to one level either way (parentId collapses to the root comment) and records the reply relationship separately in replyToUserId.

  • The server stores content verbatim — it never injects or re-adds an @handle. When the user taps "Reply", prefill @<handle> yourself; the user may edit or delete it.
  • Reply notification is driven by the relationship, not the text: the replied-to author always gets "replied to your comment" (type: comment), even if the @handle was deleted. Skipped only on a self-reply.
  • Other @mentions in the text notify those users (type: mention), deduped against the reply/comment recipient — one notification per person per comment.
  • A top-level comment notifies the post author ("commented on your post"), with the same dedup.

The response echoes the stored content, parentId (root), and replyToUserId. Render @handle tokens using the mention offsets (see §10) for tap-to-profile.

9.3 — Like a comment​

PUT /api/posts/:postId/comments/:commentId/like
{ "liked": true } // or false to unlike

Response — like state after the call:

{
"success": true,
"data": {
"liked": true,
"likeCount": 4,
"updatedAt": "2026-06-30T07:35:40.451Z",
},
}

Boolean like, one per user per comment (mirrors the post like). Works on top-level comments and replies alike. Behaviors:

  • Idempotent — repeating the same state is a no-op (liked:true when already liked stays likeCount unchanged). Safe to retry / double-tap.
  • Self-like allowed — you can like your own comment (no notification fires).
  • Notification — the comment author is notified only on the first unliked→liked transition (type: "like", data.entityType: "comment"). Unliking and re-liking does not re-notify within the same row lifetime.
  • 404 when the comment doesn't exist, isn't in :postId, or the post isn't visible to you.
  • Rate-limited 30 / 10s per user (429, no body).

Optimistic UI: flip isLiked + adjust likeCount locally on tap, fire the PUT in the background, reconcile from likeCount/updatedAt on response, roll back on HTTP error. Same pattern as the post like.

9.4 — Delete comment​

DELETE /api/posts/:postId/comments/:commentId

403 if not comment author. Cascades replies (and their likes).

9.5 — Edit comment​

PATCH /api/posts/:postId/comments/:commentId
{ "content": "Fixed typo @bob" }

Returns the updated comment (same shape as create) with editedAt set.

  • Author only, and only within 15 minutes of createdAt (Instagram-style). After that, delete is the only option.
  • Every comment object (list + create + edit, replies included) has editableUntil (ISO, server clock) — show "Edit" only while now < editableUntil; don't compute createdAt + 15min yourself (clock skew). editedAt is null until the first edit — render an Edited label when set.
  • Only content is editable; parentId / replyToUserId never change.
  • Max 5 edits per comment. editsRemaining (0–5) is on every comment object.
  • Mentions re-sync: newly added @handles get a mention notification; removed ones just lose their link. Editing never re-notifies the post author / replied-to user, and a person is notified at most once per comment — removing and re-adding their @handle does not ping them again.
  • Errors: 403 not the author · 403 with code: "COMMENT_EDIT_WINDOW_CLOSED" (window passed — hide the editor, show a toast) · 403 with code: "COMMENT_EDIT_LIMIT_REACHED" (5 edits used) · 404 comment/post missing or not visible · 422 CONTENT_BLOCKED if the text fails moderation.

10 — Mentions (@username)​

Server auto-detects @username in post + comment content. Mentioned users get a notification. Behavior is identical to existing flows — no extra work needed.


11 — Pagination cursor​

All paginated list endpoints use base64url-encoded (createdAt, id) cursors. Treat the value as opaque. Pass it back via ?cursor=.... The server returns nextCursor: null on the final page.

limit on every list endpoint in the API is held to a range. A value above the endpoint's max returns the max (100 unless the endpoint documents a lower one, for example 50 on the feed and post lists). A missing, zero, negative or non-number limit returns the default page size. It never errors. A few endpoints that validate limit strictly (post requests, mention search) still return 400 on a bad value.

Comment lists use the comment ID as the cursor (simpler than time-based for comment threads).


12 — Error responses​

Validation errors → HTTP 400 with details:

{
"success": false,
"error": "Validation error",
"details": [
{
"code": "invalid_type",
"path": ["eventAt"],
"message": "Required",
},
],
}

Other errors:

codemeaning
401Missing or invalid Authorization header
403Not the author (edit / delete), OR target profile private + viewer not approved
404Post / user / comment not found, OR author profile is private + viewer not approved
429Per-route rate limit (catalogs use a 60s/30req bucket)
500Server error — capture request_id if present

Private-profile gating: when the author of a post has set profile.isPrivate=true and the viewer is not an approved follower, every post-scoped endpoint (GET /api/posts/:id, all sub-resources, all write routes including like/save/comment) returns 404 — the post "does not exist" for the viewer (Instagram behavior). Profile-list endpoints (/api/follows/:userId/{followers,following,friends}, /api/posts/user/:userName) return 403 with { error: "private_profile" }. See docs/private-profile-implementation.md for the full surface.


13 — When the post-type list changes​

The list of post kinds is a product decision and may shift. Backend changes:

  • Add a kind → server adds enum + catalog entry + validator arm.
  • Drop a kind → server removes them.

On mobile, re-fetch /api/posts/types after each app cold-start (the 1h cache makes this cheap), and fetch /api/posts/types/:slug on kind selection (or pre-warm all five). Don't ship a hardcoded list. The catalog drives everything — picker labels, form fields, required-vs-optional, CTA labels, request-prompt defaults.


14 — Open questions​

already on the radar:

  • Per-post visibility (followers-only, friends-only, private) — not yet implemented; every post is public to authenticated users right now.
  • Drafts / scheduled posts — not yet implemented.
  • Repost / share to chat — not yet implemented.
  • Reactions beyond like — only like exists today.

15 — Type-specific CTAs & Requests​

Three of the five post kinds expose a request CTA:

kindviewer CTAauthor CTA
FIND_TEAMMATESI am interestedView Responses
INVITE_PEOPLERequest to joinView Responses
START_SOMETHINGReach outView Responses
SHARE_OPPORTUNITY(none)(none)
ANYTHING(none)(none)

All CTA labels are server-provided — never hardcode them. They come from the per-kind detail (GET /api/posts/types/:slug) and are also inlined on every post object as post.cta. The server flips buttonLabel based on viewer state (author / pending / accepted / rejected / fresh).

15.1 — Where CTA + defaults live​

GET /api/posts/types/:slug (see §1.2) returns the per-kind cta block and the defaultRequestPrompt / defaultRequestPromptDescription / defaultRequestOptions. The summary list (GET /api/posts/types) does not include these — fetch the detail when the user picks a kind.

Use the defaults as form placeholders when a user is creating a CTA-enabled post. If the author tweaks them, send the overrides on the create body.

15.2 — Authoring custom prompt/options​

CTA-enabled create requests accept three optional fields:

POST /api/posts
{
"type": "FIND_TEAMMATES",
"title": "Looking for a Debate Partner",
"content": "Need a 4th for state regionals.",
"topicSlugs": ["debate"],
"eventAt": "2026-04-25",
"deadlineAt": "2026-04-24",
"locationMode": "IN_PERSON",
"locationPlace": { "level": "CITY", "name": "San Jose", "countryCode": "US" },
"requestPrompt": "Why are you a good fit?",
"requestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
"requestOptions": [
"I have experience",
"I'm interested",
"I'm available on those dates",
"I'm highly motivated"
]
}
  • Omit all three → server fills from catalog defaults, sets requestPromptSource = "DEFAULT".
  • Provide any of them → requestPromptSource = "AUTHOR".
  • Sending any of them on SHARE_OPPORTUNITY / ANYTHING → 400.
  • Once the first PostRequest row exists, PATCH /api/posts/:id rejects edits to these three fields with 409 (snapshot integrity for existing responders).

15.3 — Post response additions (every list + detail endpoint)​

{
// ...existing fields...
"requestPrompt": "Why are you a good fit?",
"requestPromptDescription": "Pick what best describes you so the creator can review responses quickly.",
"requestOptions": ["I have experience", "I'm interested", "..."],
"requestPromptSource": "DEFAULT", // DEFAULT | AUTHOR | AI | null
"requestState": "OPEN", // OPEN | PAUSED | COMPLETED — author-controlled intake (CTA kinds; OPEN otherwise)
"cta": {
"enabled": true,
"verb": "join",
"buttonLabel": "I am interested", // resolved for current viewer + requestState
"pendingLabel": "Request Sent",
"acceptedLabel": "Joined",
"rejectedLabel": "Request Declined",
"authorLabel": "View Responses",
"pausedLabel": "Requests Paused", // shown to a fresh viewer when requestState=PAUSED
"expiredLabel": "Expired", // fresh viewer once deadlineAt has passed (isExpired)
"completedLabel": "Closed", // shown to a fresh viewer when requestState=COMPLETED
"actionable": true, // false → render the pill disabled (no tap target)
},
"viewerRequestStatus": null, // PENDING | ACCEPTED | REJECTED | WITHDRAWN | null
"_count": { "likes": 3, "comments": 5, "requests": 12 },
// ^^^^^^^^ pending count, author only (0 for others)
}

Mobile render rule for the CTA pill:

  • cta.enabled === false → don't render the pill.
  • otherwise render cta.buttonLabel — server has already swapped it for the author's "View Responses", a requester's "Request Sent" / "Joined" / "Request Declined", or (for a fresh viewer on a paused/closed post) the "Requests Paused" / "Closed" label.
  • cta.actionable === false → render the pill disabled (greyed, no tap). True only when the viewer can actually act — a fresh viewer on an OPEN post, or the author (whose "View Responses" is always tappable).
  • Use requestState to render an intake badge on the card (e.g. a "Paused" / "Closed" chip) independent of the viewer's own request status.

15.4 — Submit a request​

POST /api/posts/:id/requests
{
"selectedOptions": ["I'm interested", "I'm available on those dates"],
"message": "I've competed at state level for two years." // optional, ≤500
}
  • selectedOptions must be a subset of post.requestOptions (server validates per-string; sending an unknown option → 400).
  • Idempotent on (postId, requesterId): re-submit after a WITHDRAWN/REJECTED flips the row back to PENDING and refreshes the answers.
  • 400 if the post type has no CTA or if you're the post author.
  • 409 if you're already ACCEPTED — withdrawing is a no-op at that point.
  • 429 if you exceed 100 submissions in a rolling 24h window.

Response payload is the full PostRequest row including the embedded requester summary.

15.5 — Withdraw your request​

DELETE /api/posts/:id/requests/me

Sets status = WITHDRAWN (audit trail preserved). Only PENDING rows can be withdrawn — accepted/rejected → 409.

15.6 — Author: view responses (the "View Responses" tap target)​

GET /api/posts/:id/requests?status=PENDING&limit=20&cursor=<base64>

Author-only. Returns the request inbox for one post.

{
"success": true,
"data": {
"items": [
{
"id": "req_xxx",
"postId": "pst_yyy",
"requesterId": "usr_zzz",
"status": "PENDING",
"selectedOptions": ["I'm interested", "I'm available on those dates"],
"message": "I've competed at state level for two years.",
"respondedAt": null,
"respondedBy": null,
"createdAt": "2026-05-19T12:00:00Z",
"updatedAt": "2026-05-19T12:00:00Z",
"requester": {
"id": "usr_zzz",
"username": "adam",
"profile": { "displayName": "Adam", "avatarUrl": "..." },
},
},
],
"nextCursor": null,
"counts": { "pending": 12, "accepted": 3, "rejected": 1, "withdrawn": 2 },
},
}

counts is unfiltered (always returns totals across every status) so you can render tab badges. items honors the status filter.

15.7 — Author: accept / reject​

PATCH /api/posts/:id/requests/:reqId
{ "action": "accept" } // or "reject"

Only PENDING rows can be transitioned. Server emits notification to requester (post_request_accepted / post_request_rejected). No side-effects beyond status change in v1 — no auto chat, no event join.

15.8 — Author: aggregate inbox across all your posts​

GET /api/posts/requests/inbox?status=PENDING&limit=20

Same response shape as §15.6 but each row also includes a post summary ({ id, type, title, content, authorId, eventAt, createdAt }) so the list can show "X requested to join Looking for a Debate Partner". Default status filter is PENDING.

15.9 — Requester: my outgoing requests​

GET /api/posts/requests/mine?status=PENDING&limit=20

Returns the viewer's requests across every post they've ever requested, with the same post summary embedded. No counts block.

15.10 — Notifications​

typerecipienttrigger
post_requestpost authorviewer submits or re-submits a request
post_request_acceptedoriginal requesterauthor accepts
post_request_rejectedoriginal requesterauthor rejects
post_deletedoriginal requesterauthor deletes a post that had pending reqs

All payloads include data.entityType="post", data.entityId=<postId>, data.postType=<PostType>, plus requestId for the request-scoped ones.

15.11 — Error reference​

codemeaning
400CTA disabled / self-request / selectedOption not in requestOptions
403Not the post author (inbox / accept-reject)
404Post or request not found
409Already accepted, editing prompt after first request, or intake PAUSED/COMPLETED (§15.13)
429Per-requester rate limit (100 submissions / rolling 24h)

15.12 — Snapshot freeze​

Once the first PostRequest row exists for a post, the author cannot edit requestPrompt, requestPromptDescription, or requestOptions — server returns 409. This keeps every responder's selectedOptions stable against the chip catalog they actually saw. To "change the question", post a new one (or pause / complete the old one — see §15.13).

15.13 — Author: pause / resume / complete request intake​

The author of a CTA-enabled post controls whether it accepts new requests. This never touches existing requests — a paused or completed post keeps all its PostRequest rows, and the author can still accept/reject anything already PENDING (§15.7).

PATCH /api/posts/:id/request-state
{ "action": "pause" } // "pause" | "resume" | "complete"
  • pause — stop accepting new requests (reversible).
  • resume — reopen a paused post.
  • complete — permanently close intake. Terminal — cannot be undone.

State machine (requestState on the post object):

actionfrom OPENfrom PAUSEDfrom COMPLETED
pause→ PAUSED200 (no-op)409 (terminal)
resume200 (no-op)→ OPEN409 (terminal)
complete→ COMPLETED→ COMPLETED200 (no-op)

Author-only. Returns the full refreshed post object (same shape as GET /api/posts/:id) so the client can re-render requestState + the resolved cta in one round-trip. No notifications fire on any of these transitions.

While a post is PAUSED or COMPLETED, POST /api/posts/:id/requests (§15.4) returns 409 (also 409 "This post has expired" once deadlineAt has passed — isExpired) — surface cta.pausedLabel / cta.completedLabel / cta.expiredLabel and disable the pill (cta.actionable === false) so the viewer never taps into a 409.

codemeaning
400Post type has no request CTA (e.g. ANYTHING)
403Not the post author
404Post not found (or private + viewer not approved)
409Post already COMPLETED — intake permanently closed

16 — Save / unsave (private bookmark)​

Private bookmark — viewer-only. The post author is never notified, and the save count is never exposed to other viewers. Use this for the bookmark icon and the "Saved" tab.

16.1 — Endpoints​

PUT /api/posts/:id/save body: { saved: boolean } idempotent
GET /api/posts/saved paginated list of viewer's saved posts; optional ?type=<PostType>

16.2 — Set save state​

PUT /api/posts/:id/save
Content-Type: application/json

{ "saved": true } // or false to unsave

Idempotent. {saved: true} is a no-op if already saved; {saved: false} is a no-op if not saved. The client sends the desired state directly, so the server never has to reconstruct intent from arrival order.

Response:

{
"success": true,
"data": {
"saved": true, // echoes the requested state
"updatedAt": "2026-06-15T07:35:40.451Z",
},
}

isSaved on every post object reflects the current viewer's save state.

16.3 — Optimistic UI (mandatory pattern — same as /like)​

  1. On tap — flip isSaved immediately in the client store. Record the new value as the desired state for this post.
  2. Fire PUT /api/posts/:id/save with { saved: <desired> } in the background. No await on the UI thread.
  3. On HTTP error — roll back the local state and surface a toast.
  4. On feed refetch / detail screen open — trust the server's isSaved as source of truth.

16.4 — Race conditions​

Same rules as /like (§8.2):

  • Serialize per post — keep at most one in-flight save mutation per postId. Cancel the previous before issuing a new one.
  • Track desired state + updatedAt — store the user's latest intended saved value client-side. After each response, only apply it if its updatedAt is strictly newer than the last applied response for that post.
  • Idempotent retry on failure — safe to re-send the same body.

Rate-limited: 30 calls per 10s per user.

16.5 — Saved tab list​

GET /api/posts/saved?limit=20&cursor=<base64>

Returns the viewer's saved posts, newest save first (not newest post first — a viewer may have saved an old post recently).

Response shape identical to other list endpoints (items[] + nextCursor). Each item is normally a full post object including isSaved: true and the standard _count.

Unavailable-post shells​

A saved row never silently disappears. When the viewer can no longer access the post (author flipped private, post deleted, author hid it), the row is returned as a shell with only the post id and an unavailableReason:

{
"items": [
{
"id": "pst_abc",
"isUnavailable": true,
"unavailableReason": "RESTRICTED",
},
{
/* full normal post object */
"id": "pst_xyz",
"isSaved": true,
"isUnavailable": false, // (absent / falsy on visible posts)
// ...
},
],
"nextCursor": "...",
}

unavailableReason enum:

valuecause
RESTRICTEDAuthor flipped profile to private and viewer is not an approved follower
DELETEDPost was hard-deleted (cascade)
HIDDEN_BY_AUTHORisHidden=true (moderation / shadow-hide)

Render shells as a greyed-out card: "This post is no longer available." Tap → PUT /api/posts/:id/save with { saved: false } to remove the save.

Pass nextCursor back as ?cursor=... to fetch the next page. Stop when null. Cursor is opaque (base64url-encoded (PostSave.createdAt, id)).

16.6 — Mobile render rule​

  • Saved icon → post.isSaved ? <BookmarkFilled/> : <BookmarkOutline/>.
  • "Saved" tab in profile → GET /api/posts/saved on mount + pull-to- refresh.
  • Render shells as {isUnavailable: true} → greyed card with remove affordance.
  • No count rendered anywhere — save count is private.
  • No notification badge — save never triggers a notification.

16.7 — Errors​

codemeaning
401Missing or invalid Authorization header
404Post not found
429Rate limit (30 calls / 10s per user)

17 — Repost (one-tap, v2)​

A repost is a regular Post row with type=REPOST, content="" and originalPostId set. Renders as "X reposted" + embedded original card. One per (viewer, original), DB-enforced — re-tapping returns 409.

v2: quote reposts ("with thoughts") were removed. The endpoint takes an empty body; any non-empty body is a 400. Historical quote rows (content!="") may still appear — render their content above the embedded original. See mobile-post-interactions-guide.md §2 for the full v2 contract including repostedBy.

The kind catalog (GET /api/posts/types) does not include REPOST — mobile picker never offers it. Reposts are created exclusively via POST /api/posts/:id/repost.

17.1 — Endpoints​

POST /api/posts/:id/repost body: {} → new post object
DELETE /api/posts/:id/repost undo repost
GET /api/posts/:id/reposts repost list

17.2 — Repost a post​

POST /api/posts/:id/repost
{} // body MUST be empty

Response: full post object (same shape as POST /api/posts) with type=REPOST, originalPostId resolved to root, and the embedded original summary. Server collapses repost-of-a-repost chains — the new row always points at the root original, never another repost.

Errors:

codereason
400self-repost (can't repost your own post)
400original is hidden
400non-empty body (quotes not supported)
404original not found
409already reposted (treat as success)
429rate limit (30 reposts / 10 min)

17.3 — Post-object additions (every list + detail endpoint)​

{
// ...existing fields...
"originalPostId": "pst_root123", // null on non-reposts
"original": { // embedded; null when original was deleted
"id": "pst_root123",
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th for hackathon",
"content": "Building a flashcard app...",
"createdAt": "2026-06-10T12:00:00Z",
"author": { "id": "usr_a", "username": "alice", "profile": { ... } },
"media": [ /* same shape as top-level post.media */ ]
},
"isReposted": true, // viewer has reposted this root
"viewerRepostId": "pst_myrepost1", // viewer's own repost row id
"repostedBy": { // mutual-follow attribution; null when none
"users": [ { "id": "usr_c", "username": "charlie", "profile": { ... } } ],
"mutualCount": 5 // total MUTUAL reposters; public total = _count.reposts
},
"_count": { "likes": 3, "comments": 5, "requests": 0, "reposts": 12 }
}

repostedBy names only the viewer's mutuals (both follow each other), capped at 3 newest — "Reposted by Charlie, Dana and 3 others". Keyed on the root post, so original and repost cards agree.

Render rule:

Feed never contains type=REPOST cards — reposts boost the original into feeds and repostedBy credits them. REPOST cards appear only on profile lists and notification deep-links. Always interact via post.id.

  • type === "REPOST" + original !== null → show "X reposted" hat at top of card. Legacy quote rows: render content above the embedded original card (empty content = no body, just the original card).
  • type === "REPOST" + original === null → render placeholder ("Original removed"). Branch on original, not originalPostId: the id stays set when the original is hidden, or its author is moderation-hidden or inside the 30-day deletion window. Only a hard-deleted original nulls both.
  • type !== "REPOST" → standard render. _count.reposts is the number of times this post has been reposted; isReposted tells you if the viewer is one of them.

Like + comment buttons on a repost card act on the repost row (not the original). Tap on the embedded original card → navigate to that post's detail screen.

17.4 — Undo a repost​

DELETE /api/posts/:id/repost

:id can be the original or any post in the same repost chain — server resolves to root. Returns 409 if the viewer has no repost row.

17.5 — Repost list​

GET /api/posts/:id/reposts?limit=20&cursor=<base64>

Returns reposts of the root original, newest first (kind:"quote" = legacy rows only).

{
"success": true,
"data": {
"items": [
{
"id": "pst_repost1",
"kind": "simple", // "simple" | "quote"
"createdAt": "2026-06-15T07:00:00Z",
"user": { "id": "usr_b", "username": "bob", "profile": { ... } },
"contentPreview": "" // empty for simple
},
{
"id": "pst_repost2",
"kind": "quote",
"createdAt": "2026-06-15T06:30:00Z",
"user": { ... },
"contentPreview": "Loved this — exactly what I've been saying..."
}
],
"nextCursor": "eyJj..."
}
}

17.6 — Notifications​

typerecipienttrigger
repostroot post authorviewer simple-reposts
quote_repostroot post authorviewer quote-reposts
mentionmentioned userquote-repost contains @x

Payload data.entityId → the repost id (tap-through lands on the repost card, not the original). data.originalPostId carries the root id when the client wants to navigate to the original instead.

17.7 — Mobile UX summary​

1. User taps repost icon → bottom sheet: "Repost" | "Quote".
2A. SIMPLE → POST /api/posts/:id/repost {}.
Mobile: flip isReposted=true on the source card, bump _count.reposts.
2B. QUOTE → open compose w/ embedded original.
POST /api/posts/:id/repost { content: "my take" }.
Mobile: same flag flip + push the new repost into the user's profile feed.
3. Feed render: type=REPOST → "X reposted" hat + embedded original.
4. Undo simple → DELETE /api/posts/:id/repost.
Undo quote → DELETE /api/posts/:repostId (standard post delete).

Optimistic UI optional but recommended — server response is fast enough to skip if you'd rather just await.


18 — Share (external / URL)​

Share generates a tracked URL the viewer can paste anywhere (WhatsApp, Messages, copy-paste). Backend tracks each share for analytics + future attribution.

Status (2026-09-09): the URL is live and served — https://letsclustr.com/posts/<id>?ref=<code>. Opening it launches the app once the app claims the domain, and otherwise renders a download page. Everything about configuring that on each platform is in mobile-deep-links-guide.md; this section is only about getting the URL.

18.1 — Endpoints​

POST /api/posts/:id/share body: { channel? } → { url, shareCode, channel }
GET /api/posts/:id/share/preview → { title, description, imageUrl }

18.2 — Record a share + get URL​

POST /api/posts/:id/share
Content-Type: application/json

{ "channel": "NATIVE" }

channel is one of: WHATSAPP | TWITTER | INSTAGRAM | FACEBOOK | COPY_LINK | CHAT | NATIVE | OTHER.

Default NATIVE. Use NATIVE when invoking the OS native share sheet — the OS hides which target the user actually picked, so you can't attribute more precisely. Use specific channels only when the user picks an in-app target you know about (e.g. CHAT for in-app conversation share, COPY_LINK for copy-button).

Response:

{
"success": true,
"data": {
"url": "https://letsclustr.com/posts/019f45c6-…?ref=Ab12CdEf34",
"shareCode": "Ab12CdEf34",
"channel": "NATIVE",
},
}

Errors:

codereason
404post not found or hidden
429rate limit (60 calls / minute / user)

18.3 — Mobile flow​

1. User taps share icon on a post.
2. Mobile: POST /api/posts/:id/share { channel: "NATIVE" }.
3. Server returns { url, shareCode }.
4. Mobile passes url to iOS UIActivityViewController / Android
Intent.ACTION_SEND.
5. User picks WhatsApp / Messages / Copy / etc. URL goes out.
6. Recipient taps the link:
- app installed + domain verified → OS routes the URL into the app;
parse the postId and open the detail screen.
- app not installed → browser opens the fallback page with
App Store / Play Store buttons.

18.4 — Share-sheet preview prefill​

GET /api/posts/:id/share/preview

Returns the OG-meta values mobile can prefill in the OS share sheet so the recipient sees a meaningful link preview even before the link is fetched.

{
"success": true,
"data": {
"title": "Looking for a 4th for hackathon team", // or fallback
"description": "Building a flashcard app. Need a designer or another dev...",
"imageUrl": "https://storage.googleapis.com/…/abc.jpg", // first image, or null
},
}

Title falls back to <username> on Clustr for ANYTHING-kind posts that have no title. These values are for the OS share sheet only — the web page the recipient lands on shows branded metadata and never the post itself.

18.5 — Share count on post​

Every post object now carries _count.shares. Render alongside likes / comments / reposts:

"_count": { "likes": 3, "comments": 5, "requests": 0, "reposts": 12, "shares": 7 }

No per-viewer isShared flag — share is a transient action, not a persisted viewer state.

18.6 — In-app chat share (cleaner UX path)​

Corrected: sending a post into a chat is not a URL share. There is a native MessageType.POST_SHARE — send { postId } to POST /api/chat/conversations/:id/messages and the card is resolved per-viewer on read. See mobile-post-share-guide.md. A PostShare row with channel: CHAT is still written, so it counts here too. The URL flow in this section is for external channels only.

18.7 — What is still outstanding​

StepStatus
Domain + TLS (letsclustr.com)done — Firebase Hosting
AASA + assetlinks.jsonserved, carrying placeholder identifiers
Fallback pagedone
Mobile config (Associated Domains / intent-filter)yours — deep-links guide §3
Identifiers (team id, bundle, package, fingerprints)yours — deep-links guide §8
Click attribution (ShareClick)deferred, nothing reads ref

Until the identifiers land, a tapped link opens the browser fallback page rather than the app. That is unconfigured, not broken.

⤓ Download .md