Skip to main content
Version: Latest
UPDATED 2026-09-10 The share URL in §3.1 was wrong: it is
## What's new <!-- Newest first, plain language, max 5 entries. Every session that changes this guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. --> - **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](../deep-links/mobile-deep-links-guide.md). ---
"data": { "url": "http://localhost:3000/p/pst_xxx?ref=Ab12CdEf34", "url": "https://letsclustr.com/posts/019f45c6-…?ref=Ab12CdEf34", "shareCode": "Ab12CdEf34",
- [ ] Like/comment on repost card act on repost id, not original.- [ ] Share button: `POST /share` → pass `url` to native share sheet.- [ ] Share button: `POST /share` → pass `url` to native share sheet (never build the URL yourself).- [ ] Optional: `GET /share/preview` for richer share-sheet metadata.

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

{
"id": "pst_root123",
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th",
"content": "Hackathon team...",
"createdAt": "2026-06-10T12:00:00Z",
"author": { "id": "usr_a", "username": "alice", "profile": { "displayName": "Alice", "avatarUrl": "..." } },
"media": [ /* same shape as top-level media */ ]
}

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") {
if (post.originalPostId !== 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 was deleted. 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