Public Feed API
Endpoints for the public feed — a global, role-agnostic social layer where any
authenticated user (mentor or mentee) can post, react, comment, share/repost, run polls,
connect with other users, and report content. Unlike the mentor-owned Community feed (which is
membership-gated and scoped to a community_id), the public feed is a single cross-platform
timeline built on a mutual connection graph.
- Base URL:
/api/v1 - Auth: every endpoint requires a Sanctum bearer token —
Authorization: Bearer {token}. There is no public (unauthenticated) surface. - Content type:
application/json(image uploads usemultipart/form-data; videos are not posted inline — they go through the resumable Chunked Upload API first, and the post then references the returnedvideo_upload_id)
Surface. All endpoints live under a single
feedprefix (/api/v1/feed/...) guarded byauth:sanctum, shared by every authenticated user regardless of role. Authorship is keyed byuser_id; ownership actions (edit/delete) are enforced in the service layer (→ 403).
Scope note. Reporting is capture-only in this cut — reports are persisted (polymorphic over posts, comments and user profiles) with a
pendingstatus, but there is no admin moderation surface (list/resolve/hide) yet. Community post/comment reports flow into the samefeed_reportstable (see community-api.md §15a), so one future moderation surface covers both feeds. Reaction and comment notifications write synchronously to the database channel (no queue worker required).
Video note. A post carries either images (
attachments[]) or a single video (video_upload_id) — never both, and never a video alongside a poll. Videos are capped at 25 MB (VIDEO_MAX_SIZE) and limited tovideo/mp4,video/quicktime,video/webm. Thumbnails are generated client-side, not on the server. The frontend extracts a frame from the selected video (e.g.<video>+canvas) and uploads it as thethumbnailfield when creating the post; the server just stores it and echoes it back asvideo.thumbnail. If the client sends no thumbnail,video.thumbnailisnulland the player should fall back to the video's own first frame.
Response envelope
Success { "status": "success", "message": "…", "data": { } }
Error { "status": "error", "message": "…", "data": { } }
Validation errors (422) return field errors in data. Paginated lists are returned as a
Laravel resource-collection payload under a named key (posts, comments, users) with the
standard data / links / meta structure.
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (read, reaction, vote, accept/remove connection) |
| 201 | Resource created (post, share, comment, report) |
| 401 | Missing/invalid token |
| 403 | Authenticated but not the author (edit/delete post or comment); share not permitted; not a member of the target community |
| 404 | Post / comment / user / community not found |
| 409 | Content already reported by this user |
| 422 | Validation error / invalid state (self-connect, already connected, poll rules, expired poll, reporting your own content) |
| 500 | Server error |
Enums
Post type: text · poll · share · video
Post visibility: public · connections
Sort order (feed sort query): top (engagement-ranked, default) · recent (chronological, newest first)
Share destination (destination on the share routes): feed (the sharer's own profile feed) · community (a community they contribute to)
Shared-post source (shared_post_source, read-only): feed · community
Reaction type: like · love · laugh · wow · sad · angry
Connection status (relative to the caller): none · pending_outgoing (caller requested them) · pending_incoming (they requested the caller) · connected
Report reason: spam · harassment · hate_speech · violence · nudity · misinformation · other
Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)
Notification types: feed.connection_request · feed.connection_accepted · feed.new_follower · feed.post_commented · feed.post_reacted · feed.post_mentioned · feed.comment_mentioned
Shared objects
Post object
{
"id": "3c4d…",
"type": "text",
"type_label": "Text",
"visibility": "public",
"visibility_label": "Public",
"body": "Hello world",
"reaction_count": 4,
"comment_count": 2,
"share_count": 1,
"my_reaction": "love",
"is_seen": false,
"reactions": [
{ "type": "love", "count": 3 },
{ "type": "like", "count": 1 }
],
"image_urls": [],
"video": {
"url": "https://…/clip.mp4",
"thumbnail": "https://…/clip-thumb.jpg",
"mime": "video/mp4"
},
"hashtags": ["laravel", "php"],
"mentions": [
{ "id": "9d2f…", "username": "sarah.mitchell", "name": "Sarah Mitchell", "avatar_url": null }
],
"author": {
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"avatar_url": null,
"role": "mentor",
"connection_status": "connected",
"is_connected": true,
"is_following": false,
"is_me": false
},
"poll": null,
"shared_post": null,
"shared_post_source": null,
"comments": [ { "…comment object…" } ],
"created_at": "…",
"updated_at": "…"
}
my_reactionis the caller's own reaction type (ornull).reactionsis the per-type breakdown — an array of{ type, count }ordered by count descending, present on every post response (feed, search, show, create/update/share), and[]when there are no reactions;reaction_countremains the total.commentsis included only on the single-post (show) response, bounded to the 10 most recent top-level comments —comment_counttells you how many there are in total, and the rest page viaGET /feed/posts/{post}/comments.hashtagsis the list of tag names auto-parsed frombody(lowercased, deduplicated) — see the Hashtags section.mentionsis the list of users auto-parsed frombodyas@username— always an array, nevernull— see the Mentions section.pollis present fortype: poll. Fortype: share,shared_postis the original andshared_post_sourcesays which surface it came from —"feed"for a nested post object,"community"for a nested community post object (see community-api.md). Both keys are omitted on non-share posts. See Share / repost for how a post crosses surfaces.is_seenis the caller's own read state, present only on feed listings and omitted on single-post reads — see Seen state. Onauthor,roleis the author's platform role ("mentor","mentee","admin", ornullwhen they have none),connection_statusis the caller's relationship to the post's author (see the Connection status enum),is_connectedis a shorthand forconnection_status == "connected",is_followingis whether the caller follows the author (independent ofconnection_status— see Follow API), andis_meistruewhen the caller is the author.
Poll object (post.poll)
{
"id": "9a0b…",
"question": "Best language?",
"allows_multiple": false,
"expires_at": null,
"is_closed": false,
"total_votes": 12,
"options": [
{ "id": "…", "label": "PHP", "position": 0, "vote_count": 7, "voted_by_me": true },
{ "id": "…", "label": "JS", "position": 1, "vote_count": 5, "voted_by_me": false }
]
}
is_closed is true once expires_at has passed. voted_by_me reflects the caller's vote(s).
Comment object
{
"id": "5e6f…",
"post_id": "3c4d…",
"parent_id": "c-2",
"depth": 2,
"root_id": "c-1",
"is_deleted": false,
"parent_author": {
"id": "u-2", "username": "david.chen", "name": "David Chen", "avatar_url": null
},
"body": "@moenul same cohort, we just split the baseline.",
"mentions": [
{ "id": "u-1", "username": "moenul", "name": "Moenul Islam", "avatar_url": null }
],
"reaction_count": 1,
"my_reaction": "like",
"my_reaction_exists": true,
"reactions": [ { "type": "like", "count": 1 } ],
"author": {
"id": "7d2e…", "username": "john.doe", "name": "John Doe",
"avatar_url": null, "is_me": false
},
"replies": [ { "…comment object…" } ],
"replies_count": 12,
"created_at": "…",
"updated_at": "…"
}
Comments carry the same reaction shape as posts — reaction_count, my_reaction, and the
per-type reactions breakdown. my_reaction_exists is retained as a convenience boolean
(my_reaction !== null). author.is_me is present at every depth and decides the delete
affordance.
Threading is unlimited via parent_id — see Threading is unlimited; display is not
for depth / root_id / parent_author, the flat reply preview, and the depth-50 cap.
replies_count is omitted on the nested preview rows; treat a missing value as not applicable,
not 0.
One contract, three surfaces. This resource is field-for-field identical on the feed, community posts and the course discussion — only the parent key differs (
post_id/course_id, plusnotice_idon courses). A thread component written against this object works on all three.
Connection-user object
{ "id": "7d2e…", "name": "John Doe", "username": "john_doe", "bio": "Building things.", "avatar_url": null, "connection_status": "connected", "is_connected": true, "is_following": false, "is_me": false }
connection_status is the caller's relationship to this listed user (see the Connection
status enum); is_connected is a shorthand for connection_status == "connected". bio is the
user's profile bio — always present, null when unset. is_following is whether the caller
follows this user — independent of connection_status, never derived from it (see
Follow API). is_me is true for the caller's own row.
Hashtag object
{ "name": "laravel", "posts_count": 42, "created_at": "…" }
The tag's normalized name (lowercase, no leading #) — also its route key. posts_count is the
number of posts carrying the tag, included on the popular hashtags list and the browse-by-tag
response (omitted where not counted).
Profile object
{
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"bio": "Building things.",
"avatar_url": null,
"cover_photo_url": null,
"connections_count": 128,
"posts_count": 17,
"followers_count": 1284,
"following_count": 310,
"connection_status": "connected",
"is_connected": true,
"is_following": true,
"is_me": false
}
connection_status reflects the caller's relationship to this user (see the Connection
status enum), is_connected is a shorthand for connection_status == "connected", and is_me
is true when the profile belongs to the caller.
followers_count and following_count are public counts about this user; is_following is the
caller's follow state toward them, and is false on the caller's own profile. Following is
one-way and independent of connections — see Follow API.
Feed
A single unified feed replaces the previously separate home/discover feeds. It defaults to an
engagement-ranked order (sort=top) and accepts sort=recent for the classic chronological
order.
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
sort |
enum | top |
top (engagement-ranked) · recent (newest first). Invalid value → 422 |
per_page |
int | 15 | 1–50 |
1. Unified feed
GET /api/v1/feed → paginated unified feed
GET /api/v1/feed?sort=recent → same set, newest first
Returns, in one ranked stream:
- your own posts,
- every post by your connections (including their
connections-only posts), and - all public posts across the platform.
Under sort=top, popular public posts naturally surface alongside the connection graph via the
per-post hot_score. A stranger's connections-only post is never shown. Each post carries
my_reaction, live counters, and an author object with connection_status / is_me.
The separate
GET /api/v1/feed/discoverendpoint has been removed — its content is now part ofGET /api/v1/feed.
Ranking ("hot" score)
sort=top orders by a persisted per-post hot_score — weighted engagement over a time-decay
denominator, so posts that draw reactions/comments/shares rise while older posts fade:
hot_score = (reaction_count + 2·comment_count + 3·share_count)
/ POW(hoursSinceCreated + 2, 1.5)
The score is refreshed live on each reaction/comment/share and by a scheduled sweep, and ties
break on created_at DESC (stable pagination). Weights, gravity and the recompute window are
configurable server-side (config/feed.php) — clients only choose sort.
Seen posts are handicapped, not banished
Posts the caller has already seen are demoted — but they keep competing, so a genuinely hot
post can still outrank weak new content while an ordinary one sinks. hot_score itself is
untouched: it is a global column shared by every viewer, and seen state is per-viewer, so the
handicap is applied per query rather than baked into the score.
One knob controls it — FEED_SEEN_AGE_MULTIPLIER (default 2.5), read as "a seen post ages
this much faster". Both sorts derive from it, so they cannot disagree about how hard a seen post
is demoted:
| Sort | Effect on a seen post |
|---|---|
top |
hot_score / (multiplier ^ gravity) — at the defaults, / 3.95 |
recent |
effective age = (age + base_offset) * multiplier - base_offset |
Those are the same statement: hot_score already divides by (age + base_offset) ^ gravity, so
ageing a post by the multiplier divides its score by multiplier ^ gravity exactly.
So under top a seen post scoring 900 (→ 228) still beats an unseen post scoring 50, while a seen
post scoring 100 (→ 25) loses to an unseen 30. Under recent, a 1 h-old seen post orders as 5.5 h
old. Set the multiplier to 1 to disable the handicap; values below 1 are floored at 1 so a
misconfiguration can never promote a seen post.
The
recenthandicap is proportional, not a flat number of hours. A flat penalty's real weight depends on how fast the platform posts — the same "24 h" is a two-place demotion on a quiet feed and a sixty-place one on a busy feed. Scaling with age keeps it meaningful at any volume.
A client that never calls the seen endpoint sees no change at all — see Seen state.
2. Search
GET /api/v1/feed/searchno longer exists. Post search now lives in the global search endpoint, alongside people, communities and courses.
GET /api/v1/search?q=laravel → all four tabs, 5 rows each
GET /api/v1/search?q=laravel&tab=post → posts only, 20 rows
GET /api/v1/search?q=laravel&tab=post&sort=recent
GET /api/v1/search?h=laravel → posts carrying #laravel (implies tab=post)
GET /api/v1/search?h=%23laravel → same (a leading '#' is ignored)
Two mutually exclusive modes: q matches free text, h matches a hashtag exactly. Sending both
is a 422; sending neither is a 422. Results are capped lists — never paginated, and
results is always an object keyed by tab.
| Param | Type | Required | Notes |
|---|---|---|---|
q |
string | one of | Free-text term; 1–100 chars |
h |
string | one of | Hashtag, with or without #; 1–100 chars. Post tab only |
tab |
enum | no | post · user · community · course. Omit in q mode to get all four |
limit |
int | no | Rows per tab; 5 without tab, 20 with one. Max 50 |
sort |
enum | no | Post tab only: top (engagement-ranked, default) · recent |
scope |
enum | no | Course & community tabs: all (default) · created · member · public |
The post tab returns the same post object as the timeline and enforces the same visibility
rules: the caller's own posts and their connections' posts (any visibility) plus all public
posts — a stranger's connections-only post never appears. In q mode a post matches on its body
only; use h to match on the tag itself.
The course and community tabs cover everything the caller may see — published/public rows plus
the ones they authored or joined/enrolled in, drafts included — each flagged with is_creator and
is_enrolled / member_role. scope narrows that to a single arm.
Posts
3. Create a post
POST /api/v1/feed/posts (multipart when sending images) → 201 { "data": { "post": { … } } }
A post is text, a poll when a poll object is present, or a video when a
video_upload_id is present.
A caption is never mandatory. An image, a video or a poll is a complete post on its own, so
bodyis required only when the post would otherwise be empty. Posting a picture with no text is valid; posting nothing at all is a 422 onbody.
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | conditionally | Required only when the post carries nothing else — i.e. unless poll, video_upload_id or attachments is present; ≤ 10000 |
visibility |
enum | no | public (default) · connections |
mentioned_user_ids[] |
uuid | no | ≤ 20. Advisory only — the server re-parses body and that parse is authoritative. See Mentions. |
attachments[] |
file | no | ≤ 10 images, ≤ 10 MB each |
video_upload_id |
uuid | no | A completed feed_video chunked upload (see upload-api.md). Mutually exclusive with attachments and poll. |
thumbnail |
file | no | Client-generated video thumbnail (image, ≤ 5 MB). Only meaningful with video_upload_id. |
poll |
object | no | Presence makes the post a poll |
poll.question |
string | no | ≤ 255 |
poll.allows_multiple |
bool | no | Default false |
poll.expires_at |
date | no | Must be in the future |
poll.options |
array | with poll |
2–10 entries |
poll.options.* |
string | yes | ≤ 255 |
Video posts. Upload the file first via the Chunked Upload API (purpose
feed_video), then create the post with the returnedvideo_upload_id. The response carries avideoobject (url,thumbnail,mime); the key is omitted on non-video posts.Thumbnail. Generate it on the client and send it as the
thumbnailfield in the samemultipart/form-datarequest that creates the post. Typical browser recipe: load the file into a<video>, seek to ~1s, draw the frame to a<canvas>,canvas.toBlob(), append the blob asthumbnail. The server stores it as-is and never derives one itself, sovideo.thumbnailisnullwhenever the client omits it.
4. Show / update / delete
GET /api/v1/feed/posts/{post} → post incl. comments (per-type `reactions` are on every post response)
PUT /api/v1/feed/posts/{post} (author only, else 403) — body / visibility
DELETE /api/v1/feed/posts/{post} (author only, else 403) — cascades comments, reactions, poll
5. Share / repost
POST /api/v1/feed/posts/{post}/share → 201 { "data": { "post": { …type: share… } } }
Creates a new share post referencing the original (shared_post) and increments the
original's share_count. Sharing a share flattens to the root post (no infinite nesting) —
including across surfaces, so resharing a feed post that itself reposted a community post points
straight at that community post.
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | no | Optional quote/commentary; ≤ 10000 |
visibility |
enum | no | public (default) · connections. Only applies when the share lands on the feed |
destination |
enum | no | feed (default) · community |
target_community_id |
uuid | with destination=community |
The community to post into. Must exist, else 422 |
Sharing into a community
POST /api/v1/feed/posts/{post}/share
{ "destination": "community", "target_community_id": "…", "body": "Worth a read" }
→ 201 { "data": { "post": { …community post object… } } }
The share is created in that community, so the response is a community post object (see
community-api.md) rather than a feed post, and it carries
shared_post_source: "feed".
Two rules apply, both 403:
| Rule | Why |
|---|---|
| The caller must be an active, non-muted member of the target community | Writing a post there is a contributor action |
Only a public feed post may be carried in |
A connections-only post was addressed to the author's connections; a community roster is a different audience |
The reverse direction — a community post onto your own profile feed — is driven from the community share route with
destination=feed. See community-api.md § Share a post.
Hashtags
Hashtags are auto-parsed from a post's body on every create / update / share — clients simply
write #tags inline, there is no separate field to send. A tag must start with a letter and may
contain letters, numbers and underscores (#laravel, #dev_ops, #100DaysOfCode); tokens are
lowercased, deduplicated and capped at 100 chars, and purely numeric tokens (#123) are ignored.
Each unique tag is stored once and reused across posts; editing a post's body re-syncs its tags.
Any script is accepted, Bangla included (#বাংলা, #ঢাকা_শহর, #শুভেচ্ছা2024). A tag carries
its vowel signs, hasanta and nukta, so #বড় and #বড are two different tags; Bangla digits count as
digits, so #১২৩ is ignored like #123. Names are NFC-normalised and zero-width characters are
stripped, so the precomposed and decomposed spellings of the same tag resolve to one entry. The
100-char cap counts code points, and a Bangla syllable usually spans two or three of them.
The resolved list is returned as hashtags on the post object.
Finding posts for a tag uses global search in hashtag mode (
GET /api/v1/search?h={tag}) — there is no separate posts-by-hashtag endpoint.
6. List / search hashtags
GET /api/v1/feed/hashtags → paginated hashtag objects, most-used first
GET /api/v1/feed/hashtags?search=lara → only tags whose name contains "lara" (autocomplete)
GET /api/v1/feed/hashtags?per_page=20
Lists hashtags that are used by at least one post, ranked by posts_count (descending, newest as
tiebreaker). Pass search to filter by name — a leading # is ignored and the match is a
case-insensitive substring, so it doubles as a hashtag autocomplete. Returned as a paginated
resource-collection under hashtags.
| Param | Type | Default | Notes |
|---|---|---|---|
search |
string | — | Optional name filter (≤ 100 chars); leading # ignored, substring match |
per_page |
int | 20 | 1–50 |
Reactions
One reaction per user per target; re-reacting changes the type without double-counting.
Works on both posts and comments (reaction_count kept in sync). Reacting to another user's
post notifies its author (feed.post_reacted).
POST /api/v1/feed/posts/{post}/reactions body: { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/posts/{post}/reactions → { "data": { "reaction_count": n } }
POST /api/v1/feed/comments/{comment}/reactions body: { "type": "like" } → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/comments/{comment}/reactions → { "data": { "reaction_count": n } }
Payload: type (required, one of the reaction enum values). Invalid type → 422.
Comments
GET /api/v1/feed/posts/{post}/comments → paginated top-level comments (newest first, 3-comment subtree preview)
GET /api/v1/feed/comments/{comment}/replies → paginated descendants at any depth (oldest first) under "data": { "replies": … }
POST /api/v1/feed/posts/{post}/comments → 201 { "data": { "comment": { … } } }
DELETE /api/v1/feed/comments/{comment} (author only, else 403)
List query params (both GETs): per_page (optional, 1–50, default 15) ·
flat (replies only, optional boolean, default true).
Threading is unlimited; display is not
Storage keeps the real tree at any depth — the server never collapses, re-parents or rejects a deep reply. Each comment carries:
| Field | Meaning |
|---|---|
depth |
0 for top-level, incrementing per level. Stored, not computed. |
root_id |
The top-level ancestor's id. Always present; equals the comment's own id at depth 0. |
parent_author |
{id, username, name, avatar_url} of the comment being answered. Always present as a key, null at depth 0, and null when the parent is a tombstone. |
mentions |
MentionUser[], resolved server-side. Always an array — never null, never omitted. |
is_deleted |
Whether this row is a tombstone (see below). |
A reply may be parent_id-ed to a comment at any depth, capped at depth 50.
Reply previews are flat. The replies array on a top-level comment holds the first 3
comments of the whole subtree, chronological, at any depth — not the first 3 direct children.
replies_count is the exact total of every descendant at every depth.
replies_count is present on root comments and omitted on the nested preview rows, where a
flat display has no "view N replies" affordance. Treat a missing value as not applicable, not
0. On the replies endpoint every row carries an exact replies_count at any depth.
Replies endpoint. Returns all descendants of {comment}, at any depth, chronological and
paginated — anchoring mid-thread returns that node's subtree, not the root's. ?flat=0 re-nests
rows under parents present on the page; pagination meta still describes the flat set either
way, so a page of N rows can surface as fewer than N entries.
Create payload: body (required, ≤ 5000) · parent_id (optional, any depth, must belong to
this post) · mentioned_user_ids (optional array of ≤ 20 uuids — see Mentions). Commenting
increments the post's comment_count and notifies the post author (feed.post_commented) unless
they are the commenter or are themselves mentioned.
No reply notification. A reply notifies only the post author. The person being replied to gets nothing unless they were
@mentioned.
Deleting a comment deletes its replies
A delete takes the whole subtree. Removing a comment removes every reply beneath it, at every depth — a reply has no meaning without the comment it answers. Reactions and mentions on every removed row go with them.
comment_count drops by the number of comments actually removed — the comment plus its whole
subtree — floored at zero.
Changed behaviour. Deleting a parent used to leave a tombstone (
is_deleted: true,body: null) so the subtree stayed readable, andcomment_countdropped by exactly 1. Clients that special-cased tombstone rendering can drop that branch for newly deleted comments.
is_deletedremains on the comment object and rows created under the old behaviour are still served, so keep rendering "This comment was deleted." when you see one. New deletes simply never produce one.parent_authoris stillnullwhen the parent is a tombstone, and reacting to a tombstone is still a 422.
Errors: 403 not the author (delete) · 422 missing body; a parent_id that does not
exist or belongs to a different post ("You can only reply to a comment on this post."); a parent
already at depth 50 ("This thread has reached its maximum reply depth."); reacting to a
tombstone.
Mentions
A body is stored verbatim, as typed: Hey @david.chen, look at this. No markup, no
@[Name](id) wrapper. On write the server parses the body, resolves the handles to users, and
persists them; on read it returns them as mentions[]. A username that later changes simply stops
resolving, so the text degrades to plain text rather than to a broken link.
mentions[] appears on comment objects and on post objects, in body order, capped at 20.
The parse rule
The client mirrors this in src/pages/feed/utils/parse-rich-text.ts; the two must agree.
/(?<![\p{L}\p{N}_.@])@([a-zA-Z0-9][a-zA-Z0-9._-]{0,29})/u
- The character before
@must not be a letter, digit, underscore, dot, or another@. - A handle starts alphanumeric and may contain
.,_,-. - 30 characters max —
users.usernameisvarchar(30).
| Input | Handle | Note |
|---|---|---|
@bob (start of body) |
bob |
|
hey @bob |
bob |
|
(@bob) |
bob |
punctuation before @ is fine |
@david.chen, |
david.chen |
trailing comma isn't a handle char |
@david. (end of sentence) |
david |
see trailing-punctuation rule |
@bob's |
bob |
|
[email protected] |
— | not a mention |
a@b |
— | not a mention |
x.@y |
— | not a mention |
@@bob |
— | not a mention |
Trailing punctuation. ., - and _ are legal interior handle characters but almost always
sentence punctuation when trailing. Resolution is greedy: both the raw capture and its
rtrim('.-_') form are looked up, and whichever matches a real username wins.
Case-insensitive. @BOB resolves bob. Match mentions[] back to handles case-insensitively
when rendering.
Who may be mentioned
- Feed — the author's accepted connections, plus anyone already in the thread (the post's author and every commenter).
- Community — active members of that community only. Not connections, so nobody can be pulled into a private community by name.
A handle outside that set is dropped silently — never a 422 — and the body text stays as typed.
mentioned_user_ids is advisory
final mentions = eligible( resolve( parse(body) ) )
The hint from the composer's picker is validated for shape (nullable, array, max:20, each a
uuid) so a malformed payload still 422s, but it can neither add a mention (otherwise an empty
body could ping twenty people) nor remove one (that would drop hand-typed and pasted handles).
Sending it is optional and never changes the outcome; we keep it as a client-drift signal.
Suggestions
GET /api/v1/feed/mentions?search=dav&per_page=8&post_id={post}
→ { "data": { "users": { "data": [ { id, username, name, avatar_url } ], … } } }
searchmatches bothnameandusername, case-insensitive, ranked exact → prefix → substring. Emptysearchreturns the whole eligible set so the list can open the moment@is typed.post_id(optional) ranks the post author first, then thread participants, then everyone else.- Self, banned and inactive users are excluded.
With no interaction-recency signal to rank on, the empty-
searchorder is participant tier thenfull_nameascending — the same orderGET /feed/connectionsuses.
Note that GET /feed/connections has no search parameter and never had one; this endpoint
supersedes that use.
Polls
POST /api/v1/feed/posts/{post}/poll/vote body: { "option_ids": ["…"] } → { "data": { "poll": { … } } }
DELETE /api/v1/feed/posts/{post}/poll/vote → retracts the caller's vote(s)
Voting replaces any previous vote by the caller. Rules:
- Single-choice polls (
allows_multiple: false) reject more than one option → 422. - Multi-choice polls accept multiple
option_ids. - Options must belong to the poll → 422 otherwise.
- Voting on a closed poll (past
expires_at) → 422.
Per-option vote_count and the caller's voted_by_me flags are recomputed and returned, on
both the vote and the retract response.
One vote per poll, guaranteed. Writes against a single poll are serialised behind a row lock, so rapidly changing your mind — a double tap, or a client retry on a slow response — cannot leave a single-choice poll holding a vote on two options, and
vote_countcannot drift away from the underlying votes. The response is always the poll as it stands after your write, withvoted_by_mescoped to you.
Profile
GET /api/v1/feed/users/{username} → { "data": { "user": { …profile object… } } }
Shows a single user's public feed profile, resolved by username (not the UUID used by the
connection/reaction routes). Returns connection/post counts, the caller's connection_status, and
an is_me flag. An unknown username → 404.
This is the trimmed profile, sized for a feed card. For the complete profile — role, expertise areas, experiences, education, certifications, location, and social links — call
GET /api/v1/user/{username}(see the Authentication API docs).
GET /api/v1/feed/users/{username}/posts → paginated posts by this author, newest first
GET /api/v1/feed/users/{username}/posts?sort=top
Lists the author's posts (same post object and pagination shape as the timeline), also
resolved by username. Visibility is enforced: the author sees all their own posts; a
connection additionally sees the author's connections-only posts; everyone else sees
public posts only. Unknown username → 404.
Newest first, unlike the timeline. This endpoint defaults to
sort=recent, so the most recent entry leads — posts and shares alike. A profile is a record of what someone has been posting, not a ranked discovery surface, and ranking byhot_scoreburied a fresh post under an older one that had picked up engagement. Pass?sort=topto rank instead.per_pagebehaves as on the timeline.Seen state does not apply here. A profile is not a discovery feed, so
is_seenis absent and the ordering is never reshuffled by what the caller has read.
Connections
A mutual relationship established by a request/accept handshake (LinkedIn-style). One user sends a request; the other accepts; both are then connected. Either side can remove it.
Connecting also follows. Sending a request creates the follow in the same transaction, because Connect is the stronger signal and asking for both would be two presses for one intention. The implication runs one way only: accepting a request does not make the accepter follow, and removing a connection does not unfollow. Each response reports the resulting
is_followingso you never have to infer it. See Follow API.
POST /api/v1/feed/connections/{user} → 201 { "data": { "connection_status": "pending_outgoing", "is_following": true } }
POST /api/v1/feed/connections/{user}/accept → { "data": { "connection_status": "connected", "is_following": false } }
DELETE /api/v1/feed/connections/{user} → { "data": { "connection_status": "none", "is_following": true } }
GET /api/v1/feed/connections → paginated connection-user objects (caller's connections)
GET /api/v1/feed/connections/pending → paginated connection-user objects (incoming requests)
GET /api/v1/feed/connections/sent → paginated connection-user objects (outgoing requests you sent)
GET /api/v1/feed/users/{username}/connections → paginated connection-user objects (a user's connections)
Mind the binding. The three
connections/{user}write routes resolve the target by UUID. Theusers/{username}/connectionslist resolves by username, matching the other profile-shaped routes (users/{username},users/{username}/posts). Passing a UUID there returns 404.
- Send a request (
POST connections/{user}) — creates apendingedge and notifies the target (feed.connection_request). Idempotent for a request you already sent. If the target had already sent you a request, this auto-accepts it (returnsconnected). Connecting with yourself → 422; requesting someone you're already connected to → 422. - Accept (
POST connections/{user}/accept) — accepts an incoming pending request from{user}and notifies the requester (feed.connection_accepted). No pending request → 422. - Remove (
DELETE connections/{user}) — one idempotent verb that cancels a request you sent, declines an incoming request, or removes an existing connection. - Incoming vs outgoing —
connections/pendinglists requests others sent you (rows carryconnection_status: "pending_incoming");connections/sentlists requests you sent (rows carrypending_outgoing), so a user can review andDELETE(cancel) them. - Lists annotate each row with the caller's
connection_statusand acceptper_page.
Suggested profiles
GET /api/v1/feed/suggestions → paginated profiles to connect with
GET /api/v1/feed/suggestions?per_page=20
A paginated "who to connect with" list, ranked by how well each candidate matches the caller. The caller and anyone they already have an edge with (pending or connected) are excluded; inactive/banned users are omitted.
Ranking blends three signals (higher is better):
score = shared_expertise_areas * 3
+ mutual_connections * 2
+ LN(connections_count + 1)
- shared_expertise_areas — overlap between the caller's and the candidate's expertise areas, drawn from both mentor and mentee profiles.
- mutual_connections — people connected to the caller who are also connected to the candidate (connection-graph proximity).
- connections_count — overall popularity (log-dampened tiebreaker).
When the caller has no expertise signal, ranking degrades gracefully to the connection-graph and
popularity terms. Each row exposes the "why" (and always connection_status: "none", since linked
users are excluded):
{
"id": "7d2e…",
"username": "john_doe",
"name": "John Doe",
"bio": "Building things.",
"avatar_url": null,
"connections_count": 128,
"mutual_connections_count": 3,
"shared_expertise_count": 2,
"connection_status": "none",
"is_following": false
}
connection_status is always "none" here — suggestions exclude anyone you already have a
connection edge with. is_following is not always false: a follow does not disqualify a
suggestion, so a suggested profile may be someone you already follow.
| Param | Type | Default | Notes |
|---|---|---|---|
per_page |
int | 15 | 1–50 |
Report
POST /api/v1/feed/posts/{post}/report → 201
POST /api/v1/feed/comments/{comment}/report → 201
POST /api/v1/feed/users/{username}/report → 201
Payload: reason (required, one of the report-reason enum values), details (optional,
≤ 2000). One report per user per target — a duplicate returns 409. Reports are stored with
status: pending; no moderation surface consumes them yet (see below).
Reporting a profile targets the user, resolved by username like the other
profile-shaped routes (passing a UUID → 404). The report is stored against the user globally,
so the same person reported from the feed and from inside a community is still one report per
reporter.
You cannot report your own content → 422. Reporting your own post, comment or profile returns "You cannot report your own content." (or "You cannot report your own profile."). This previously succeeded with a 201 and consumed the reporter's one-per-target slot.
| Code | Meaning |
|---|---|
| 201 | Report captured |
| 403 | Community route only — not a member (see community-api.md) |
| 404 | Target not found |
| 409 | Already reported by this caller |
| 422 | Invalid reason, or the target is the caller's own content |
Seen state
As the viewer scrolls, the client reports which posts appeared on screen. Subsequent feed fetches push those posts below ones the viewer has not seen, so a refresh surfaces fresh content instead of repeating the same rows.
POST /api/v1/feed/posts/seen
{ "post_ids": ["3c4d…", "5e6f…"] }
→ 200 { "data": { "marked": 2 } }
| Field | Type | Required | Notes |
|---|---|---|---|
post_ids[] |
uuid | yes | 1–100 per call. Batch a fast scroll rather than firing per post |
marked is the number of posts newly marked — not the number sent.
Contract
- Idempotent. A post the caller already saw keeps its original timestamp and is not re-marked,
so a repeat call returns
marked: 0. Retry freely; there is no need to de-duplicate client-side. - Unknown ids are dropped, not rejected. A post deleted while the viewer scrolled past it is not a client error, and one stale id never fails the batch.
- Per-caller. Seen state is private to the caller and never affects anyone else's feed.
- Never expires. There is no automatic reset in this cut.
- Sending more than 100 ids, an empty array, or a non-uuid → 422.
Reading it back. Every post on a feed listing carries is_seen (boolean). The key is
omitted on single-post reads and on profile listings, where the feed has not computed it —
treat a missing value as not applicable, not false.
⚠️ Paging: always echo seen_cursor
Ordering depends on state the client is itself changing as it scrolls, and the feed pages by offset. Without a snapshot, marking page 1 seen reorders the result set underneath the offset and page 2 skips whatever moved past it — real content loss, not cosmetic shuffling.
Every feed response therefore carries a seen_cursor:
{ "data": { "posts": { "data": [ … ], "links": { … }, "meta": { … } },
"seen_cursor": "2026-08-16T11:34:49.482+00:00" } }
Pass it back as seen_before on every subsequent page of the same session. The feed then
orders against seen state as it was at that instant, so anything you mark mid-scroll cannot move
the rows under you.
GET /api/v1/feed?per_page=15 ← page 1, no cursor
GET /api/v1/feed?per_page=15&page=2&seen_before=2026-08-16T11:34:49.482+00:00
GET /api/v1/feed?per_page=15&page=3&seen_before=2026-08-16T11:34:49.482+00:00
Drop the cursor on an explicit refresh, or whenever you want the newest seen state applied — the response hands you a fresh one every time.
| Param | Type | Notes |
|---|---|---|
seen_before |
datetime | The seen_cursor from page 1 of this session. Omit on page 1. Malformed → 422 |
Why it works.
seen_atis written once and never refreshed, at millisecond precision, and the cursor comparison is strict (seen_at < cursor). A mark written at the instant the cursor was issued therefore belongs to the session doing the paging, not to the history it orders against.
The community feed has its own endpoint —
POST /api/v1/mentee/communities/{community}/posts/seen, same payload and semantics. The two surfaces keep separate seen state; marking a feed post never marks a community post. See community-api.md.
Deferred / cross-phase (not in this cut)
| Item | Present | Unblocks with |
|---|---|---|
| Admin moderation surface (list/resolve reports, hide/remove content) | feed_reports captured (status column) |
Future moderation cycle |
| Queued notification fan-out; email / push channels | database channel (synchronous) | Phase 10 — Notifications / queue worker |
| Blocking / mute between users | — | Future feed cycle |
| "Catch me up" — resetting seen state | Seen state captured; reset exists in the service but is not routed | Future feed cycle |
| Mentions / advanced full-text search (relevance-ranked, typo-tolerant) | LIKE-based post search over body + hashtag shipped (see Search & Hashtags) |
Future feed cycle |