Skip to main content
Version: 1.0

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

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

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"],
"optional": ["media", "location", "deadlineAt"],
},
"cta": {
"enabled": true,
"verb": "join",
"buttonLabel": "Request to Join",
"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, eventAtmedia, location, deadlineAt
invite-peopletitle, content, topicSlugs, location, eventAtmedia
start-somethingtitle, content, topicSlugsmedia, location, locationMode
share-opportunitytitle, content, topicSlugs, location, eventAtmedia, link
anythingcontentmedia

Field meanings:

field nametypesent as
titlestring ≤120body.title
contentstring ≤2000body.content (description body)
topicSlugsstring[] (1–10)body.topicSlugs — from /api/users/topics
mediaobject[] (≤10)body.media — see §3
locationstring ≤200body.location
locationModeenumbody.locationMode (CITY/SCHOOL/ONLINE/HYBRID)
eventAtISO 8601 datetimebody.eventAt
deadlineAtISO 8601 datetimebody.deadlineAt
linkURLbody.link

2 — Topics dropdown (interest tags)​

GET /api/users/topics. Already used by onboarding — same response shape.

Groups: each topic has a category field. Render as a sectioned picker.

{
"success": true,
"data": [
{
"id": "tpc_xxx",
"slug": "sports",
"name": "Sports",
"category": "Lifestyle",
"icon": "trophy",
},
{
"id": "tpc_yyy",
"slug": "music",
"name": "Music",
"category": "Arts",
"icon": "music",
},
// ...
],
}

Send the chosen slug values back as topicSlugs: ["sports","music"]. Unknown slugs are silently dropped server-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" }
]
}

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

<binary bytes>

No auth headers — the signed URL is the credential.

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 | video | gif
"url": "https://s3.../uploads/abc.jpg",
"thumbnailUrl": "https://...", // optional
"width": 1080, // optional
"height": 1920, // optional
"size": 245678, // optional, bytes
"duration": 12, // optional, seconds (video)
"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.

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": ["coding", "design"],
"eventAt": "2026-06-12T10:00:00Z", // event date
"deadlineAt": "2026-06-10T23:59:59Z", // optional cut-off
"location": "San Francisco", // optional
"media": [ { "type": "image", "url": "..." } ]
}

4.2 — Invite People​

{
"type": "INVITE_PEOPLE",
"title": "Beach cleanup this Saturday",
"content": "Bring gloves. We'll provide bags.",
"topicSlugs": ["volunteering"],
"location": "Ocean Beach",
"eventAt": "2026-05-25T09:00:00Z",
}

4.3 — Start Something New​

{
"type": "START_SOMETHING",
"title": "Starting a robotics club",
"content": "Anyone interested in meeting weekly to build robots?",
"topicSlugs": ["robotics", "engineering"],
"locationMode": "HYBRID", // ONLINE / HYBRID / CITY / SCHOOL
"location": "Lincoln High School",
}

4.4 — Share an Opportunity​

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

4.5 — Post Anything​

{
"type": "ANYTHING",
"content": "Just had the best burrito of my life.",
"media": [{ "type": "image", "url": "..." }],
}

4.6 — Response shape (all kinds)​

{
"success": true,
"data": {
"id": "pst_xxx",
"type": "FIND_TEAMMATES",
"title": "...",
"content": "...",
"location": "...",
"locationMode": null,
"eventAt": "2026-06-12T10:00:00Z",
"deadlineAt": null,
"link": null,
"details": null,
"authorId": "usr_yyy",
"isHidden": false,
"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
upcomingOnly"true" → require 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, eventAtlocation, deadlineAtlocationMode, link
INVITE_PEOPLEtitle, content, topicSlugs, location, eventAtlinklocationMode, deadlineAt
START_SOMETHINGtitle, content, topicSlugslocation, locationMode, linkeventAt, deadlineAt
SHARE_OPPORTUNITYtitle, content, topicSlugs, location, eventAtlinklocationMode, deadlineAt
ANYTHINGtopicSlugs, contentlocationtitle, locationMode, eventAt, deadlineAt, link
REPOST——all typed fields (reposts aren't editable)

Rules that apply to every type:

  • topicSlugs REPLACES the topic set (never merged, cannot be null).
  • 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.
  • Time-bound posts freeze after the event. Once eventAt has passed, 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, drop the deadline, remove a photo:

PATCH /api/posts/pst_xxx
{
"title": "Updated title",
"content": "Updated description",
"topicSlugs": ["coding", "design", "art"],
"eventAt": "2026-06-15T10:00:00Z", // required for this type — value OK, null would 400
"deadlineAt": null, // optional for this type — null clears it
"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",
"location": null, // optional for this type — clears it
"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-01T10:00:00Z" (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 returns the liker list, sorted with the viewer's followers/following first.

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


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.

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_TEAMMATESRequest to JoinView Responses
INVITE_PEOPLERequest to AttendView Responses
START_SOMETHINGRequest to CollaborateView 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-25T10:00:00Z",
"location": "San Jose, CA",
"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": "Request to Join", // 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
"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 — surface cta.pausedLabel / cta.completedLabel 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

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" + originalPostId !== 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" + originalPostId === null → original was deleted. Render placeholder ("Original removed").
  • 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.


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

Status note (2026-06-15): Phase C.1 is shipped — the endpoint works and returns a real URL, but the URL is a placeholder until the landing page route ships in Phase C.2 (after the first mobile build target exists). For now: safe to copy-paste, but won't open the app or render a preview. See docs/share-implementation.md for the full phase plan.

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://ksn.app/p/pst_xxx?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 receives the link. Tap behaviour depends on Phase C.2:
- app installed + Universal Link verified → OS routes URL into app,
extract postId, navigate to detail screen.
- app not installed → browser opens landing page (smart banner +
App Store / Play Store redirect).

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://cdn.ksn.app/uploads/abc.jpg", // first image, or null
},
}

Title falls back to <username> on Kids Social Network for ANYTHING-kind posts that have no title.

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

For "send to chat" inside the app:

  • Pick channel: "CHAT" when calling POST /share.
  • Use the returned url as the body of a chat message (current).
  • Future: the chat schema may add a dedicated MessageType=POST_SHARE with data.postId for native render — not yet implemented; carry the URL for now.

18.7 — Phase C.2 (deferred — depends on mobile build)​

Outstanding work, blocked on the existence of a published mobile build:

StepDescription
Buy domainksn.app or similar; configure DNS + TLS
AASA file/.well-known/apple-app-site-association route
App Links/.well-known/assetlinks.json route
Landing pageGET /p/:postId HTML with OG meta + smart banner
Mobile configiOS Associated Domains + Android intent-filter
Click attributionPOST /api/links/click + ShareClick model

Until then, the URL returned by POST /share is safe to copy-paste but won't open the app or render a rich preview. Mobile can ship the share button + native sheet today; the link just becomes useful when Phase C.2 lands. See docs/share-implementation.md for the full Phase C.2 plan.