UPDATED 2026-09-10 — The share URL in §3.1 was wrong: it is
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") andGET /posts/:idreturns 404. Per-post visibility (Everyone / Friends) is deferred — seedocs/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>, nothttp://localhost:3000/p/<id>. The link is live now — it opens the app, or a download page if the app isn't installed. Treatrefas opaque. Platform setup: mobile-deep-links-guide.md.
At-a-glance endpoint table
| Method | Path | Use |
|---|---|---|
PUT | /api/posts/:id/save | bookmark on/off |
GET | /api/posts/saved | "Saved" tab list |
POST | /api/posts/:id/repost | one-tap repost (empty body) |
DELETE | /api/posts/:id/repost | undo simple-repost |
GET | /api/posts/:id/reposts | list reposters |
POST | /api/posts/:id/share | record share + get URL |
GET | /api/posts/:id/share/preview | share-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):
- On tap: flip
isSavedlocally. No spinner. - Fire PUT in background.
- On HTTP error: roll back + toast.
- Track
updatedAtper postId — discard response if older than last applied. - 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
| code | meaning |
|---|---|
| 401 | bad auth |
| 404 | post not found |
| 429 | rate 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/reposttakes an empty body — any non-empty body is a 400. Historical quote rows (content!="") may still appear in feeds and inGET /: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
| code | meaning |
|---|---|
| 400 | self-repost / non-empty body |
| 404 | original not found, hidden, or not visible to you — hidden was a 400 before 2026-08-25; render your existing not-found state |
| 409 | already reposted (POST — treat as success) / no repost to remove (DELETE) |
| 429 | rate limit (30 / 10 min) |
2.6 — Notifications
| type | recipient | tap-target |
|---|---|---|
repost | root post author | data.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_SENDwithEXTRA_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 /sharewithchannel: "CHAT"→ take the returnedurl, send it as a regular chat message (URL preview rendered by chat UI). - Future: dedicated
MessageType=POST_SHAREwith embeddedpostIdis on the roadmap (cleaner native render). Not implemented yet — use the URL path for now.
3.5 — Errors
| code | meaning |
|---|---|
| 404 | post not found or hidden |
| 429 | rate limit (60 / minute per user) |
4 — Optimistic UI cheatsheet
| Action | Optimistic? | Race protection |
|---|---|---|
| like | yes (existing) | updatedAt per postId |
| save | yes (mandatory) | updatedAt per postId |
| repost (simple) | optional — fast enough to await | n/a |
| repost (quote) | no — compose screen waits | n/a |
| share | n/a — call returns the URL, share happens after | n/a |
Save mirrors like exactly. Same updatedAt discard rule (apply only if
response updatedAt > last applied for this postId).
5 — Rate limits
| Endpoint | Limit |
|---|---|
PUT /:id/save | 30 / 10s per user |
POST /:id/repost | 30 / 10 min per user |
POST /:id/share | 60 / 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)
| Action | Idle icon | Active state |
|---|---|---|
| Save | bookmark outline | bookmark filled when isSaved |
| Repost | repeat (two-arrow) | green/accent tint when isReposted |
| Share | share-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.repostsincludes 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
/sharewithchannel: "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.sharesto your Post type. - Save button: optimistic flip +
PUT /save+updatedAtreconcile. - 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 + embeddedoriginal). - Like/comment on repost card act on repost id, not original.
- Share button:
POST /share→ passurlto native share sheet (never build the URL yourself). - Optional:
GET /share/previewfor richer share-sheet metadata. - Handle notifications:
repost,quote_repost. Tap → navigate todata.entityId(the repost).
That's it.