MeetyyAPI
Documentation / API Reference / Public Feed

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 use multipart/form-data; videos are not posted inline — they go through the resumable Chunked Upload API first, and the post then references the returned video_upload_id)

Surface. All endpoints live under a single feed prefix (/api/v1/feed/...) guarded by auth:sanctum, shared by every authenticated user regardless of role. Authorship is keyed by user_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 pending status, but there is no admin moderation surface (list/resolve/hide) yet. Community post/comment reports flow into the same feed_reports table (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 to video/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 the thumbnail field when creating the post; the server just stores it and echoes it back as video.thumbnail. If the client sends no thumbnail, video.thumbnail is null and 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_reaction is the caller's own reaction type (or null). reactions is 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_count remains the total. comments is included only on the single-post (show) response, bounded to the 10 most recent top-level commentscomment_count tells you how many there are in total, and the rest page via GET /feed/posts/{post}/comments. hashtags is the list of tag names auto-parsed from body (lowercased, deduplicated) — see the Hashtags section. mentions is the list of users auto-parsed from body as @username — always an array, never null — see the Mentions section. poll is present for type: poll. For type: share, shared_post is the original and shared_post_source says 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_seen is the caller's own read state, present only on feed listings and omitted on single-post reads — see Seen state. On author, role is the author's platform role ("mentor", "mentee", "admin", or null when they have none), connection_status is the caller's relationship to the post's author (see the Connection status enum), is_connected is a shorthand for connection_status == "connected", is_following is whether the caller follows the author (independent of connection_status — see Follow API), and is_me is true when 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 postsreaction_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, plus notice_id on 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/discover endpoint has been removed — its content is now part of GET /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 recent handicap 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/search no 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 body is required only when the post would otherwise be empty. Posting a picture with no text is valid; posting nothing at all is a 422 on body.

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 returned video_upload_id. The response carries a video object (url, thumbnail, mime); the key is omitted on non-video posts.

Thumbnail. Generate it on the client and send it as the thumbnail field in the same multipart/form-data request 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 as thumbnail. The server stores it as-is and never derives one itself, so video.thumbnail is null whenever 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, and comment_count dropped by exactly 1. Clients that special-cased tombstone rendering can drop that branch for newly deleted comments.

is_deleted remains 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_author is still null when 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 maxusers.username is varchar(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 } ], … } } }
  • search matches both name and username, case-insensitive, ranked exact → prefix → substring. Empty search returns 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-search order is participant tier then full_name ascending — the same order GET /feed/connections uses.

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_count cannot drift away from the underlying votes. The response is always the poll as it stands after your write, with voted_by_me scoped 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 by hot_score buried a fresh post under an older one that had picked up engagement. Pass ?sort=top to rank instead. per_page behaves as on the timeline.

Seen state does not apply here. A profile is not a discovery feed, so is_seen is 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_following so 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. The users/{username}/connections list 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 a pending edge 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 (returns connected). 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 outgoingconnections/pending lists requests others sent you (rows carry connection_status: "pending_incoming"); connections/sent lists requests you sent (rows carry pending_outgoing), so a user can review and DELETE (cancel) them.
  • Lists annotate each row with the caller's connection_status and accept per_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_at is 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 endpointPOST /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