Mobile Follow Graph Guide
Instagram-style follow graph: follow / unfollow, public vs private profiles, pending requests, and how the profile screen degrades when the viewer is not allowed to see a private account.
All routes below are authenticated (Authorization: Bearer <access>).
Prefixes: follow graph = /api/follows, profile header = /api/users,
posts grid = /api/posts.
Companion to mobile-verification-guide.md.
1. Mental Model
There is no separate "public/private" fork on the follow button — the
same POST /api/follows/:userId handles both. The server decides the
resulting state from the target's profile.isPrivate:
none --follow--> ACCEPTED (target is PUBLIC — instant)
none --follow--> PENDING (target is PRIVATE — needs their approval)
PENDING --they accept--> ACCEPTED
PENDING --they reject / I cancel--> none
ACCEPTED|PENDING --I unfollow--> none
Friends = mutual ACCEPTED in both directions. Derived, no table.
The client never sends "please make this pending" — it just follows, reads
the returned state, and renders the right label. requestedByMe: true means
the target was private and you now have a pending request.
2. Relation State (drives every button)
Every follow/unfollow/state call returns the same shape:
type RelationState = "none" | "pending" | "accepted";
interface FollowStateView {
iFollow: RelationState; // my edge → them
theyFollow: RelationState; // their edge → me
requestedByMe: boolean; // iFollow === "pending"
requestedByThem: boolean; // theyFollow === "pending"
isFriend: boolean; // both accepted
isSelf?: boolean; // only present on GET /state/:userId
}
Button label matrix
Render off iFollow (primary) with theyFollow as tiebreaker:
iFollow | theyFollow | Button | Tap action |
|---|---|---|---|
none | none | Follow | POST /:userId |
none | accepted | Follow Back | POST /:userId |
pending | any | Requested | DELETE /:userId (cancels request) |
accepted | not accepted | Following | DELETE /:userId (unfollow) |
accepted | accepted | Friends | DELETE /:userId (unfollow) |
isSelf: true → hide the button entirely (show Edit Profile instead).
3. Follow / Unfollow
Follow
POST /api/follows/:userId # :userId = target's user ID
No body. Idempotent — following an already-followed user just returns current state, no duplicate, no repeat notification.
Response 200 → FollowStateView:
{ "success": true, "data": {
"iFollow": "pending", "theyFollow": "none",
"requestedByMe": true, "requestedByThem": false, "isFriend": false
}}
- Public target →
iFollow: "accepted", target gets afollownotification. - Private target →
iFollow: "pending", target gets afollow_requestnotification. This is the private-profile path.
Errors:
| Code | When | UX |
|---|---|---|
| 400 | Following yourself | Should be unreachable — hide button on own profile. |
| 403 | You/they blocked either way | "Cannot follow this user." |
| 404 | Target user gone | Pop back / refresh. |
Unfollow / cancel request
DELETE /api/follows/:userId
Idempotent. Same call unfollows an accepted edge AND cancels a
pending request — both are "delete my outgoing edge." Returns fresh
FollowStateView. Optimistically flip the button to Follow on tap.
Read state on its own
GET /api/follows/state/:userId
Returns FollowStateView + isSelf. Use it when you land on a profile and
need the button state without mutating. This is the call in your logs that
correctly returns 200 even for private accounts — state is never gated.
4. Private Profiles: what you get vs what's blocked
This is the Instagram model. The profile header is always visible; the grid and social lists are locked until you're an approved follower.
Always visible (NO gate) — render the header for anyone
GET /api/users/:username
Returns for public AND private targets:
{ "success": true, "data": {
"id": "...", "username": "zoya_sketch", "createdAt": "...",
"profile": {
"displayName": "Zoya", "bio": "...", "avatarUrl": "...",
"location": "...", "website": "...", "isPrivate": true
},
"_count": { "followers": 42, "following": 88, "posts": 17 },
"isFollowing": false, "requestedByMe": false,
"isFollower": false, "requestedByThem": false, "isFriend": false
}}
So even on a locked account you show: avatar, display name, bio, location, website, and follower / following / post counts. Enough for a full header + a Follow button.
Blocked when isPrivate: true AND you are not an approved follower
These return 403 private_profile:
| Route | Content |
|---|---|
GET /api/posts/user/:userName | posts grid |
GET /api/follows/:userId/followers | followers list |
GET /api/follows/:userId/following | following list |
GET /api/follows/:userId/friends | mutuals list |
"Approved follower" = you have an ACCEPTED follow on them (iFollow === "accepted"). Pending does not unlock. Blocked (either direction) also
returns 403 here — and the header 404s/hides in that case.
The 403 is expected — do NOT retry
403 { "message": "private_profile" }
Treat it as a state, not an error. On 403:
- Keep the header (already fetched from
/api/users/:username). - Replace the grid/list with a locked empty state: "🔒 This account is private. Follow to see their posts."
- Stop fetching. Do not re-request on focus/scroll/retry — your logs showed 4 rapid re-hits; that's a client retry loop, not a server bug.
Gate the grid fetch on isPrivate && !isFollowing so you never fire the
request you know will 403:
const locked = profile.isPrivate && !profile.isFollowing;
const posts = useQuery({
queryKey: ["posts", username],
queryFn: () => getUserPosts(username),
enabled: !locked, // don't even ask when locked
retry: false, // and never retry a 403
});
When the follow request is later accepted, isFollowing flips to true,
enabled turns on, and the grid loads. No special-casing.
5. Follow Requests (the private side, both directions)
When you follow a private account you become an incoming request on their side. Manage from either end:
Requests I received (approve/deny inbox)
GET /api/follows/requests/incoming?limit=20&cursor=<id>
POST /api/follows/requests/:userId/accept # :userId = the requester
POST /api/follows/requests/:userId/reject # silent — no notification
incoming items: { followId, createdAt, user }. Accept → requester gets a
follow_accepted notification and can now see your content. Reject silently
deletes the request (they're not told).
Requests I sent (still pending)
GET /api/follows/requests/outgoing?limit=20&cursor=<id>
Cancel any of them with DELETE /api/follows/:userId (same unfollow call).
Notification types to route on
type | Fires when | Deep link |
|---|---|---|
follow | public follow / follow-back | ksn://users/<username> |
follow_request | someone requests a private me | ksn://follow-requests |
follow_accepted | my request was approved | ksn://users/<username> |
6. Lists & Suggestions
All list routes are cursor-paginated (?limit=&cursor=), return
{ items, nextCursor }, and each item carries isFollowing / isFollower
so you can render a per-row Follow button without extra /state calls.
GET /api/follows/:userId/followers # 403 private_profile if locked
GET /api/follows/:userId/following # 403 private_profile if locked
GET /api/follows/:userId/friends # 403 private_profile if locked
GET /api/follows/suggestions?limit=10 # never gated; excludes existing edges + blocks
Same locked-state handling as the grid: skip the fetch when
isPrivate && !isFollowing.
7. Reference Snippets
const H = { headers: { "X-Client-Type": "mobile" } }; // + auth interceptor
export async function follow(userId: string) {
const { data } = await api.post(`/api/follows/${userId}`, null, H);
return data.data as FollowStateView;
}
export async function unfollow(userId: string) {
const { data } = await api.delete(`/api/follows/${userId}`, H);
return data.data as FollowStateView; // also cancels a pending request
}
export async function getFollowState(userId: string) {
const { data } = await api.get(`/api/follows/state/${userId}`, H);
return data.data as FollowStateView & { isSelf: boolean };
}
export async function getProfile(username: string) {
// Header only — safe for private accounts, never 403s.
const { data } = await api.get(`/api/users/${username}`, H);
return data.data;
}
export async function acceptRequest(requesterId: string) {
await api.post(`/api/follows/requests/${requesterId}/accept`, null, H);
}
export async function rejectRequest(requesterId: string) {
await api.post(`/api/follows/requests/${requesterId}/reject`, null, H);
}
// Button label from state.
export function followLabel(s: FollowStateView): string {
if (s.iFollow === "pending") return "Requested";
if (s.iFollow === "accepted") return s.isFriend ? "Friends" : "Following";
return s.theyFollow === "accepted" ? "Follow Back" : "Follow";
}
curl
# Follow a private account → pending request
curl -X POST http://localhost:3001/api/follows/<userId> \
-H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"
# Header loads even when locked
curl http://localhost:3001/api/users/zoya_sketch \
-H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"
# Grid is 403 when locked
curl -i http://localhost:3001/api/posts/user/zoya_sketch \
-H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"
8. Gotchas
private_profile(403) is not an error — it's the locked UI state. Never toast it, never retry it. Gate the fetch onisPrivate && !isFollowingand short-circuit to a lock screen.- Header ≠ content.
/api/users/:username(header + counts) is ungated for privacy; the grid/lists are gated. Fetch the header first, always; fetch content only when unlocked. The one exception is a block — see below. - Pending does not unlock content. Only an
ACCEPTEDfollow (iFollow === "accepted") lets you read a private user's posts/lists. Apendingrequest still 403s. - Follow is idempotent; so is unfollow. Safe to fire on double-tap. One
DELETEcovers both "unfollow" and "cancel my request" — no separate cancel endpoint. :userIdis a user ID, not a username on every/api/follows/*route (accept/reject too — it's the requester's ID). The header route/api/users/:usernameand posts route/api/posts/user/:userNametake the username. Don't cross the wires.- Counts are ACCEPTED-only.
_count.followers/followingexclude pending requests, so a private user's header count matches what an approved follower would see in the list. - Blocks gate before privacy. A blocked viewer gets 403 on follow and
never learns the target's private/public state — the block check runs
first.
/api/users/:usernamereturns 404 on either side of a block (not 403, not a locked header). Treat it as "unavailable," not "private," and never retry. Seemobile-report-block-guide.md. - Optimistic UI: flip the button on tap, reconcile with the returned
FollowStateView. For a private target, expectFollow → Requested(notFollowing) — read the response, don't assume.