Skip to main content
Version: Latest

Feed API — Mobile Integration Guide

The personalized home feed: a scored, paginated blend of posts from people you follow, interest matches, and trending content. This doc is for the mobile (React Native) client. Feed internals: feed-algorithm.md.

Endpoint

GET /api/feed?cursor=<opaque>&limit=<1-50>
Authorization: Bearer <accessToken>
X-Client-Type: mobile
QueryDefaultNotes
limit20Page size. Hard-capped at 50.
cursorOpaque continuation token from the previous response. Omit for page 1.

Response 200

{
"success": true,
"data": {
"items": [ /* serialized posts, ranked */ ],
"nextCursor": "eyJ...==",
"mode": "warm"
}
}
  • nextCursor — pass back as ?cursor= to load the next page. null = end of feed.
  • mode — which strategy produced this feed. Informational (analytics / debug), safe to ignore for rendering:
    • cold — new user (interest/trending only).
    • warm — established user (follow + interest + trending blend).
    • fallback:following | fallback:any-recent | fallback:own | fallback:none — sparse graph; the normal pools were empty and the feed degraded gracefully.

Item shape

Every item is a canonical serialized post — identical shape to GET /api/posts/*, so reuse your post card component as-is. See Post Data Structure for the full field list (author, media, topics, isLiked, isSaved, isReposted, interestStatus, likeCount, commentCount, CTA, repost original, …).

Feed items add two ranking fields:

score: number; // frozen rank score (debug/analytics)
pool: "follow" | "interest" | "trending"; // which pool sourced this item

Pagination — frozen snapshots

The ranked feed is computed once per page-1 request, frozen server-side, and paged by slicing that frozen list. This matters for the client:

  • A no-cursor GET /api/feed rebuilds — fresh ranking, fresh content, freshly applied exclusions and seen penalties. This is your pull-to-refresh.
  • Cursor pages are stable — no re-ranking between pages, so no duplicates and no skips mid-scroll. Follow nextCursor until it's null for infinite scroll.
  • Snapshots live ~5 minutes. An old/expired cursor doesn't error — the server rebuilds from the top. Rare, but don't treat a cursor as permanent.

Consumption model — what leaves the feed, and when

The feed separates learning (what you'll see next) from selection (what's a candidate now). Interacting with a post never re-ranks the post you just acted on.

Liked & commented posts are removed (next build)

A post you like or comment on is consumed — it is excluded from every future feed build for you, in every mode. It still lives on the author's profile, your liked list, search, notifications, and its direct link — just not the home feed. This kills the old "I liked it, refreshed, and it came back" bug.

Timing follows the snapshot rules above:

  • Within the current frozen snapshot it will not resurface (dedup + freeze), so mid-scroll is already safe — you don't need to do anything.
  • On the next no-cursor refresh it's gone from the list.

Seen posts are down-ranked automatically

Posts already served to you are recorded server-side and gently demoted on the next build (graded — seen once is a small nudge, seen repeatedly sinks lower), so refreshing rotates content instead of showing the same top posts. Nothing for the client to send — seen tracking is automatic today.

Future: viewport-accurate impression tracking (POST /feed/impression with the ids ~70% visible) is planned but not built. Do not call it yet. Until then the server counts a post as seen when it's returned in a page.

  • Pull-to-refreshGET /api/feed with no cursor. Fresh snapshot with liked/commented removed and seen posts rotated down.
  • Infinite scroll → append items, follow nextCursor, stop at null.
  • On like / comment → update the card's isLiked / counts optimistically and leave the card in place for the current session (the frozen snapshot won't duplicate or bounce it). If your design prefers it gone immediately, optimistically remove it from the local list — safe, because the snapshot can't resurface it. No page-1 refetch is required just to hide a liked post.
  • On not_interested → see Post Interest (harder, permanent exclusion + immediate topic-weight shift).
  • Don't rebuild the feed on every interaction — refresh on explicit pull-to-refresh, screen re-focus, or when the list runs out.

Examples

TOKEN=... # from POST /api/auth/login (alice@example.com / Password123)

# Page 1
curl -s localhost:3001/api/feed -H "Authorization: Bearer $TOKEN" | jq '.data.mode, (.data.items | length), .data.nextCursor'

# Next page
curl -s "localhost:3001/api/feed?cursor=<NEXT_CURSOR>" -H "Authorization: Bearer $TOKEN" | jq '.data.items[].id'

# Verify a liked post is gone after refresh — index must be null
curl -X PUT localhost:3001/api/posts/<POST_ID>/like -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"liked":true}'
curl -s localhost:3001/api/feed -H "Authorization: Bearer $TOKEN" \
| jq '[.data.items[].id] | index("<POST_ID>")'

Swagger UI: http://localhost:3001/api/docs (Feed → GET /api/feed).

⤓ Download .md