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 withisUnavailable: truerather than dropping. Per-post visibility (Everyone / Friends) is deferred to a future sprint — seedocs/post-visibility-implementation.md.
- API base URL (local):
http://localhost:3001 - All requests are JSON. Send
Content-Type: application/json. - Send
X-Client-Type: mobileon 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:
| slug | required | optional |
|---|---|---|
find-teammates | title, content, topicSlugs, eventAt | media, location, deadlineAt |
invite-people | title, content, topicSlugs, location, eventAt | media |
start-something | title, content, topicSlugs | media, location, locationMode |
share-opportunity | title, content, topicSlugs, location, eventAt | media, link |
anything | content | media |
Field meanings:
| field name | type | sent as |
|---|---|---|
title | string ≤120 | body.title |
content | string ≤2000 | body.content (description body) |
topicSlugs | string[] (1–10) | body.topicSlugs — from /api/users/topics |
media | object[] (≤10) | body.media — see §3 |
location | string ≤200 | body.location |
locationMode | enum | body.locationMode (CITY/SCHOOL/ONLINE/HYBRID) |
eventAt | ISO 8601 datetime | body.eventAt |
deadlineAt | ISO 8601 datetime | body.deadlineAt |
link | URL | body.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):
| param | meaning |
|---|---|
type | FIND_TEAMMATES/INVITE_PEOPLE/START_SOMETHING/SHARE_OPPORTUNITY/ANYTHING |
topicSlug | only posts tagged with this topic |
authorUsername | only posts by this user |
upcomingOnly | "true" → require eventAt >= now() |
cursor | from previous response's nextCursor |
limit | default 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 sent | null sent |
|---|---|---|
| required for the type | applied | 400 — cannot be cleared |
| optional for the type | applied | applied — clears the field |
| not in the type's form | 400 — not supported | silently 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
| type | editable, can't clear (null → 400) | editable + clearable (null clears) | rejected if value sent (null ignored) |
|---|---|---|---|
FIND_TEAMMATES | title, content, topicSlugs, eventAt | location, deadlineAt | locationMode, link |
INVITE_PEOPLE | title, content, topicSlugs, location, eventAt | link | locationMode, deadlineAt |
START_SOMETHING | title, content, topicSlugs | location, locationMode, link | eventAt, deadlineAt |
SHARE_OPPORTUNITY | title, content, topicSlugs, location, eventAt | link | locationMode, deadlineAt |
ANYTHING | topicSlugs, content | location | title, locationMode, eventAt, deadlineAt, link |
REPOST | — | — | all typed fields (reposts aren't editable) |
Rules that apply to every type:
topicSlugsREPLACES the topic set (never merged, cannot benull).- Media cannot be added on edit (media is create-only). To remove existing
media, pass
removeMediaIds— an array of mediaids (from the post'smedia[]). 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
eventAthas passed, any edit returns 409 ("Post can no longer be edited after its event date"). Posts withouteventAtnever 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
| code | meaning |
|---|---|
| 400 | Value for a field the type doesn't have, or null for a required-for-type field |
| 403 | Not the post author |
| 404 | Post not found (or author private + viewer not approved) |
| 409 | Event 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.
- On tap — flip
isLikedand bump_count.likesby+1(like) or-1(unlike) immediately in the client store. Record the new value as the desired state for this post. - Fire
PUT /api/posts/:id/likewith{ liked: <desired> }in the background. Noawaiton the UI thread. - On HTTP error — roll back the local state and surface a toast ("Couldn't update — tap to retry"). Do not block other interactions.
- On feed refetch / detail screen open — trust the server's
_count.likesandisLikedas 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] })insideonMutate. - Track desired state — store the user's latest intended
likedvalue client-side. After each response, only apply it if itsupdatedAtis strictly newer than the last applied response for that post. Discard olderupdatedAtresponses — 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
contentverbatim — 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@handlewas deleted. Skipped only on a self-reply. - Other
@mentionsin 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:truewhen already liked stayslikeCountunchanged). 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:
| code | meaning |
|---|---|
| 401 | Missing or invalid Authorization header |
| 403 | Not the author (edit / delete), OR target profile private + viewer not approved |
| 404 | Post / user / comment not found, OR author profile is private + viewer not approved |
| 429 | Per-route rate limit (catalogs use a 60s/30req bucket) |
| 500 | Server 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
likeexists today.
15 — Type-specific CTAs & Requests
Three of the five post kinds expose a request CTA:
| kind | viewer CTA | author CTA |
|---|---|---|
FIND_TEAMMATES | Request to Join | View Responses |
INVITE_PEOPLE | Request to Attend | View Responses |
START_SOMETHING | Request to Collaborate | View 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/:idrejects 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 anOPENpost, or the author (whose "View Responses" is always tappable).- Use
requestStateto 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
}
selectedOptionsmust be a subset ofpost.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
| type | recipient | trigger |
|---|---|---|
post_request | post author | viewer submits or re-submits a request |
post_request_accepted | original requester | author accepts |
post_request_rejected | original requester | author rejects |
post_deleted | original requester | author 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
| code | meaning |
|---|---|
| 400 | CTA disabled / self-request / selectedOption not in requestOptions |
| 403 | Not the post author (inbox / accept-reject) |
| 404 | Post or request not found |
| 409 | Already accepted, editing prompt after first request, or intake PAUSED/COMPLETED (§15.13) |
| 429 | Per-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):
| action | from OPEN | from PAUSED | from COMPLETED |
|---|---|---|---|
pause | → PAUSED | 200 (no-op) | 409 (terminal) |
resume | 200 (no-op) | → OPEN | 409 (terminal) |
complete | → COMPLETED | → COMPLETED | 200 (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.
| code | meaning |
|---|---|
| 400 | Post type has no request CTA (e.g. ANYTHING) |
| 403 | Not the post author |
| 404 | Post not found (or private + viewer not approved) |
| 409 | Post 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)
- On tap — flip
isSavedimmediately in the client store. Record the new value as the desired state for this post. - Fire
PUT /api/posts/:id/savewith{ saved: <desired> }in the background. Noawaiton the UI thread. - On HTTP error — roll back the local state and surface a toast.
- On feed refetch / detail screen open — trust the server's
isSavedas 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
savedvalue client-side. After each response, only apply it if itsupdatedAtis 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:
| value | cause |
|---|---|
RESTRICTED | Author flipped profile to private and viewer is not an approved follower |
DELETED | Post was hard-deleted (cascade) |
HIDDEN_BY_AUTHOR | isHidden=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/savedon 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
| code | meaning |
|---|---|
| 401 | Missing or invalid Authorization header |
| 404 | Post not found |
| 429 | Rate 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. Seemobile-post-interactions-guide.md§2 for the full v2 contract includingrepostedBy.
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:
| code | reason |
|---|---|
| 400 | self-repost (can't repost your own post) |
| 400 | original is hidden |
| 400 | non-empty body (quotes not supported) |
| 404 | original not found |
| 409 | already reposted (treat as success) |
| 429 | rate 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=REPOSTcards — reposts boost the original into feeds andrepostedBycredits them. REPOST cards appear only on profile lists and notification deep-links. Always interact viapost.id.
type === "REPOST"+originalPostId !== null→ show "X reposted" hat at top of card. Legacy quote rows: rendercontentabove the embeddedoriginalcard (empty content = no body, just the original card).type === "REPOST"+originalPostId === null→ original was deleted. Render placeholder ("Original removed").type !== "REPOST"→ standard render._count.repostsis the number of times this post has been reposted;isRepostedtells 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
| type | recipient | trigger |
|---|---|---|
repost | root post author | viewer simple-reposts |
quote_repost | root post author | viewer quote-reposts |
mention | mentioned user | quote-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 (backend ready, link infra pending mobile build)
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.mdfor 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:
| code | reason |
|---|---|
| 404 | post not found or hidden |
| 429 | rate 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 callingPOST /share. - Use the returned
urlas the body of a chat message (current). - Future: the chat schema may add a dedicated
MessageType=POST_SHAREwithdata.postIdfor 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:
| Step | Description |
|---|---|
| Buy domain | ksn.app or similar; configure DNS + TLS |
| AASA file | /.well-known/apple-app-site-association route |
| App Links | /.well-known/assetlinks.json route |
| Landing page | GET /p/:postId HTML with OG meta + smart banner |
| Mobile config | iOS Associated Domains + Android intent-filter |
| Click attribution | POST /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.