Skip to main content
Version: Latest

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:

iFollowtheyFollowButtonTap action
nonenoneFollowPOST /:userId
noneacceptedFollow BackPOST /:userId
pendinganyRequestedDELETE /:userId (cancels request)
acceptednot acceptedFollowingDELETE /:userId (unfollow)
acceptedacceptedFriendsDELETE /: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 a follow notification.
  • Private target → iFollow: "pending", target gets a follow_request notification. This is the private-profile path.

Errors:

CodeWhenUX
400Following yourselfShould be unreachable — hide button on own profile.
403You/they blocked either way"Cannot follow this user."
404Target user gonePop 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:

RouteContent
GET /api/posts/user/:userNameposts grid
GET /api/follows/:userId/followersfollowers list
GET /api/follows/:userId/followingfollowing list
GET /api/follows/:userId/friendsmutuals 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:

  1. Keep the header (already fetched from /api/users/:username).
  2. Replace the grid/list with a locked empty state: "🔒 This account is private. Follow to see their posts."
  3. 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

typeFires whenDeep link
followpublic follow / follow-backksn://users/<username>
follow_requestsomeone requests a private meksn://follow-requests
follow_acceptedmy request was approvedksn://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 on isPrivate && !isFollowing and 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 ACCEPTED follow (iFollow === "accepted") lets you read a private user's posts/lists. A pending request still 403s.
  • Follow is idempotent; so is unfollow. Safe to fire on double-tap. One DELETE covers both "unfollow" and "cancel my request" — no separate cancel endpoint.
  • :userId is 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/:username and posts route /api/posts/user/:userName take the username. Don't cross the wires.
  • Counts are ACCEPTED-only. _count.followers/following exclude 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/:username returns 404 on either side of a block (not 403, not a locked header). Treat it as "unavailable," not "private," and never retry. See mobile-report-block-guide.md.
  • Optimistic UI: flip the button on tap, reconcile with the returned FollowStateView. For a private target, expect Follow → Requested (not Following) — read the response, don't assume.
⤓ Download .md