Skip to main content
Version: Latest
UPDATED 2026-11-04 — When an original post is deleted, its reposts are deleted too. They no longer show "Original removed" in profiles and lists. Drop any reposts you hold in local state for that post. Reposts that were orphaned before today still show the placeholder.
- **2026-11-04** — When an original post is deleted, its reposts are deleted too. They no longer show "Original removed" in profiles and lists. Drop any reposts you hold in local state for that post. Reposts that were orphaned before today still show the placeholder.- **2026-10-08** — **The guide was wrong:** it said to branch a repost on `originalPostId`. Branch on `original` instead. `original: null` means "Original removed" even when `originalPostId` is still set, which happens whenever the original is hidden or its author is unavailable (and, from today, when the author is inside the 30-day deletion window). Also: comments and likes by an account in its 30-day deletion window (or moderation-hidden) are left out of comment threads, `total`, and the likers list. The post's `_count` isn't recounted, so it can be a little higher than the rows you get.

Mobile Post Interactions — Integration Guide

Save, repost, share. Everything mobile needs. Backend already shipped. Companion to mobile-posts-guide.md — read that first for post-object shape + auth headers.

Visibility model: the active gate today is profile-level only (docs/private-profile-implementation.md). Saving / reposting / sharing a post by an author whose profile is currently public works normally; if that author later flips private and the viewer is not approved, the saved-list returns a shell (isUnavailable: true, unavailableReason: "RESTRICTED") and GET /posts/:id returns 404. Per-post visibility (Everyone / Friends) is deferred — see docs/post-visibility-implementation.md.

  • Base URL: http://localhost:3001 (dev) — production URL from backend team
  • Auth: Authorization: Bearer <accessToken> on every endpoint
  • Headers: Content-Type: application/json, X-Client-Type: mobile

What's new​

  • 2026-11-04 — When an original post is deleted, its reposts are deleted too. They no longer show "Original removed" in profiles and lists. Drop any reposts you hold in local state for that post. Reposts that were orphaned before today still show the placeholder.
  • 2026-10-08 — The guide was wrong: it said to branch a repost on originalPostId. Branch on original instead. original: null means "Original removed" even when originalPostId is still set, which happens whenever the original is hidden or its author is unavailable (and, from today, when the author is inside the 30-day deletion window). Also: comments and likes by an account in its 30-day deletion window (or moderation-hidden) are left out of comment threads, total, and the likers list. The post's _count isn't recounted, so it can be a little higher than the rows you get.
  • 2026-10-05 — original on a repost now carries the full original post, so a repost card can render exactly like the original's card: location, mode, event/deadline, link, details, topics, author grade/school, counts, request prompt/options, requestState, cta, plus your own viewerRequestStatus / viewerRequestConversationId. Nothing removed — existing fields unchanged. Per-viewer isLiked / isSaved are not embedded; act on original.id.
  • 2026-09-09 — The share URL in §3.1 was wrong: it is https://letsclustr.com/posts/<id>?ref=<code>, not http://localhost:3000/p/<id>. The link is live now — it opens the app, or a download page if the app isn't installed. Treat ref as opaque. Platform setup: mobile-deep-links-guide.md.

At-a-glance endpoint table​

MethodPathUse
PUT/api/posts/:id/savebookmark on/off
GET/api/posts/saved"Saved" tab list
POST/api/posts/:id/repostone-tap repost (empty body)
DELETE/api/posts/:id/repostundo simple-repost
GET/api/posts/:id/repostslist reposters
POST/api/posts/:id/sharerecord share + get URL
GET/api/posts/:id/share/previewshare-sheet prefill

Post object — new fields​

Every post (detail, list, feed, /me, /user/:name, /saved) now carries:

{
// existing fields...
"isLiked": false,
"isSaved": false, // viewer's bookmark state
"isReposted": false, // viewer has reposted this
"viewerRepostId": null, // viewer's repost row id
"originalPostId": null, // set on repost rows; null on regular posts
"original": null, // embedded original-post summary on reposts
"repostedBy": null, // mutual-follow repost attribution (see §2.4)
"_count": {
"likes": 0,
"comments": 0,
"requests": 0,
"reposts": 0, // new
"shares": 0 // new
// saves intentionally omitted — private
}
}

original shape (only present when type === "REPOST" + original not deleted). Same fields as the original's own list card, except isLiked / isSaved (fetch the original if you need them) and location is the coarse city only:

{
"id": "pst_root123",
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th",
"content": "Hackathon team...",
"location": "Mumbai", // coarse, same rule as list cards
"locationMode": "ONLINE",
"eventAt": "2026-09-24T18:30:00Z",
"deadlineAt": "2026-09-21T18:30:00Z",
"link": null,
"details": null,
"authorId": "usr_a",
"isAiGenerated": false,
"createdAt": "2026-06-10T12:00:00Z",
"author": { "id": "usr_a", "username": "alice", "grade": "12th", "school": "...", "profile": { "displayName": "Alice", "avatarUrl": "..." } },
"topics": [ { "id": "...", "slug": "academics-learning", "name": "Academics & Learning", "icon": null } ],
"media": [ /* same shape as top-level media */ ],
"_count": { "likes": 0, "comments": 0, "reposts": 1, "shares": 0 },
"requestPrompt": "Why are you a good fit?",
"requestPromptDescription": "...",
"requestOptions": ["I have experience"],
"requestPromptSource": "AUTHOR",
"requestState": "OPEN",
"cta": { /* resolved for YOU, same as a normal post */ },
"viewerRequestStatus": null,
"viewerRequestConversationId": null
}

1 — Save (private bookmark)​

Private. No notification to author. No public count.

1.1 — Toggle save​

PUT /api/posts/:id/save
{ "saved": true } // or false

Response:

{ "success": true, "data": { "saved": true, "updatedAt": "2026-06-15T07:35:40.451Z" } }

Optimistic UI (mandatory):

  1. On tap: flip isSaved locally. No spinner.
  2. Fire PUT in background.
  3. On HTTP error: roll back + toast.
  4. Track updatedAt per postId — discard response if older than last applied.
  5. On refetch: server is truth, overwrite local.

Rate-limited 30/10s per user.

1.2 — Saved tab list​

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

Returns { items, nextCursor } — full post objects, ordered by save time (not post time). Cursor is base64url; treat as opaque, pass back as ?cursor=. Stop when nextCursor === null.

1.3 — Errors​

codemeaning
401bad auth
404post not found
429rate limit

2 — Repost (one-tap, v2)​

A repost is a Post row with type=REPOST, content="" and originalPostId set. Renders as "X reposted" + embedded original.

v2 change: quote reposts ("with thoughts") were removed. POST /:id/repost takes an empty body — any non-empty body is a 400. Historical quote rows (content!="") may still appear in feeds and in GET /:id/reposts (kind:"quote"); render their content above the embedded original as before.

Server collapses chains — repost-of-a-repost always anchors at the root. One repost per user per original, DB-enforced. REPOST never appears in the kind picker (GET /api/posts/types filters it out).

2.1 — Create​

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

Response: full post object (same shape as POST /api/posts) with type=REPOST, originalPostId populated, embedded original.

After response: bump _count.reposts on the source card by +1, set isReposted=true, store viewerRepostId from response.

409 = already reposted. Treat as success client-side: set isReposted=true and leave the count alone (a previous tap already won).

2.2 — Undo​

DELETE /api/posts/:id/repost

:id = original (or any post in same chain — server resolves to root). Sets isReposted=false, decrement _count.reposts.

2.3 — Reposters list​

GET /api/posts/:id/reposts?limit=20&cursor=<opaque>
{
"success": true,
"data": {
"items": [
{
"id": "pst_repost1",
"kind": "simple", // "simple" | "quote" (quote = legacy rows only)
"createdAt": "2026-06-15T07:00:00Z",
"user": { "id": "usr_b", "username": "bob", "profile": { ... } },
"contentPreview": "" // empty for simple
}
],
"nextCursor": "eyJj..."
}
}

2.4 — Render rules + repostedBy​

The feed only ever contains original posts. You will not get type=REPOST cards in GET /api/feed — a repost boosts the original into feeds, and repostedBy tells you who to credit. Use post.id for everything (like, comment, navigate); there is no second id to worry about.

type=REPOST cards appear only on profile lists (GET /posts/me, GET /posts/user/:name — "you/they reposted" entries) and when a notification deep-links to a repost id:

if (post.type === "REPOST") {
// Branch on `original`, not `originalPostId` — the id stays set when the
// original is hidden or its author is unavailable (moderation-hidden, or in
// the 30-day deletion window).
if (post.original !== null) {
// Hat: "<author.username> reposted"
// Legacy quote rows: if post.content !== "" render it above the original.
// Embedded original card = post.original.
// Tap on embedded original card → navigate to post.original.id.
} else {
// Original deleted, hidden, or its author unavailable.
// Render "Original removed" placeholder.
// (Only case where a REPOST card can also reach the feed.)
}
} else {
// Standard render. _count.reposts shows how many times reposted.
// isReposted tells you if the viewer is one of them.
}

repostedBy — every post (list, feed, detail) carries mutual-follow repost attribution. Only the viewer's mutuals (both follow each other) are ever named; strangers never leak:

"repostedBy": {
"users": [ // up to 3, newest first
{ "id": "usr_c", "username": "charlie", "profile": { "displayName": "Charlie", "avatarUrl": "..." } }
],
"mutualCount": 5 // total MUTUAL reposters (public total = _count.reposts)
}
// null when no mutual has reposted

Render: "Reposted by Charlie", "Reposted by Charlie, Dana and 3 others". It is keyed on the root post, so an original card and a repost card of it show the same attribution. May lag follow/unfollow changes by up to 5 minutes (server-side mutual cache).

2.5 — Errors​

codemeaning
400self-repost / non-empty body
404original not found, hidden, or not visible to you — hidden was a 400 before 2026-08-25; render your existing not-found state
409already reposted (POST — treat as success) / no repost to remove (DELETE)
429rate limit (30 / 10 min)

2.6 — Notifications​

typerecipienttap-target
repostroot post authordata.entityId = repost id

data.originalPostId carried alongside if you want to navigate to the original instead. (quote_repost / repost mention notifications no longer fire — historical rows may still exist with those types.)


3 — Share​

Generates a tracked URL the viewer can paste anywhere. Records each share.

Status: backend ready. Tap-through to the app (Universal Links / App Links) lands in a later phase once a mobile build exists. Right now the URL is safe to copy-paste but does not open the app or render a rich preview when the recipient taps it. Ship the button anyway — flip on link infra later without API changes.

3.1 — Get share URL​

Call this before opening the native share sheet:

POST /api/posts/:id/share
{ "channel": "NATIVE" }

channel values: WHATSAPP | TWITTER | INSTAGRAM | FACEBOOK | COPY_LINK | CHAT | NATIVE | OTHER

Use NATIVE for the OS share sheet (default). Use a specific channel only when the user picks an in-app target you know about (e.g. CHAT for in-app conversation share, COPY_LINK for the copy button).

Response:

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

Pass url to:

  • iOS: UIActivityViewController
  • Android: Intent.ACTION_SEND with EXTRA_TEXT

3.2 — Prefill share sheet preview​

Optional but recommended — improves the link preview the recipient sees:

GET /api/posts/:id/share/preview
{
"success": true,
"data": {
"title": "Looking for a 4th for hackathon team",
"description": "Building a flashcard app...",
"imageUrl": "https://cdn.../uploads/abc.jpg"
}
}

iOS — attach as UIActivityItemSource metadata. Android — pass through the share Intent extras (EXTRA_TITLE, EXTRA_TEXT).

3.3 — Share-count on post​

post._count.shares increments after each successful POST /share. No per-viewer isShared flag — share is a transient action, not a state.

3.4 — In-app chat share​

Two paths:

  • Today: POST /share with channel: "CHAT" → take the returned url, send it as a regular chat message (URL preview rendered by chat UI).
  • Future: dedicated MessageType=POST_SHARE with embedded postId is on the roadmap (cleaner native render). Not implemented yet — use the URL path for now.

3.5 — Errors​

codemeaning
404post not found or hidden
429rate limit (60 / minute per user)

4 — Optimistic UI cheatsheet​

ActionOptimistic?Race protection
likeyes (existing)updatedAt per postId
saveyes (mandatory)updatedAt per postId
repost (simple)optional — fast enough to awaitn/a
repost (quote)no — compose screen waitsn/a
sharen/a — call returns the URL, share happens aftern/a

Save mirrors like exactly. Same updatedAt discard rule (apply only if response updatedAt > last applied for this postId).


5 — Rate limits​

EndpointLimit
PUT /:id/save30 / 10s per user
POST /:id/repost30 / 10 min per user
POST /:id/share60 / minute per user

All return 429 with no body when exceeded. Debounce taps that happen faster than a human can tap.


6 — UI mapping (suggested icons + states)​

ActionIdle iconActive state
Savebookmark outlinebookmark filled when isSaved
Repostrepeat (two-arrow)green/accent tint when isReposted
Shareshare-arrow (iOS up-arrow / Android tri-dot)static — no toggle

Render rules:

  • Save / repost / share counts: hide when 0 (or grey out — product call).
  • post._count.reposts includes both simple and quote reposts.
  • Saved icon should not show a count anywhere.

7 — Open questions for product​

  • Show count beside repost icon, or hide until non-zero?
  • Long-press share = "copy link" shortcut? (Would call POST /share with channel: "COPY_LINK".)
  • "Saved" tab placement: profile sub-tab, or settings shortcut?
  • Quote-repost compose screen: full-screen modal or sheet?

Backend doesn't care — answers shape only mobile UX.


8 — Quick integration checklist​

  • Add isSaved, isReposted, viewerRepostId, originalPostId, original, _count.reposts, _count.shares to your Post type.
  • Save button: optimistic flip + PUT /save + updatedAt reconcile.
  • Saved tab: GET /api/posts/saved + cursor paginate.
  • Repost button: bottom-sheet → simple (POST /repost {}) or quote (POST /repost { content }).
  • Feed render: handle type === "REPOST" (hat + embedded original).
  • Like/comment on repost card act on repost id, not original.
  • Share button: POST /share → pass url to native share sheet (never build the URL yourself).
  • Optional: GET /share/preview for richer share-sheet metadata.
  • Handle notifications: repost, quote_repost. Tap → navigate to data.entityId (the repost).

That's it.

⤓ Download .md